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/README.md +2 -2
- package/dist/agent/index.d.ts +3 -2
- package/dist/agent/index.js +813 -566
- package/dist/agent/index.js.map +1 -1
- package/dist/bash-CGAqW7HR.d.ts +80 -0
- package/dist/cli.js +3044 -1081
- package/dist/cli.js.map +1 -1
- package/dist/db/index.d.ts +67 -3
- package/dist/db/index.js +252 -13
- package/dist/db/index.js.map +1 -1
- package/dist/{index-BxpkHy7X.d.ts → index-Btr542-G.d.ts} +18 -2
- package/dist/index.d.ts +178 -77
- package/dist/index.js +2537 -976
- package/dist/index.js.map +1 -1
- package/dist/{schema-EPbMMFza.d.ts → schema-CkrIadxa.d.ts} +371 -5
- package/dist/server/index.d.ts +9 -2
- package/dist/server/index.js +2483 -945
- package/dist/server/index.js.map +1 -1
- package/dist/tools/index.d.ts +5 -138
- package/dist/tools/index.js +787 -723
- package/dist/tools/index.js.map +1 -1
- package/package.json +4 -2
package/dist/cli.js
CHANGED
|
@@ -13,16 +13,21 @@ import "dotenv/config";
|
|
|
13
13
|
import { createInterface } from "readline";
|
|
14
14
|
|
|
15
15
|
// src/server/index.ts
|
|
16
|
+
import "dotenv/config";
|
|
16
17
|
import { Hono as Hono5 } from "hono";
|
|
17
18
|
import { serve } from "@hono/node-server";
|
|
18
19
|
import { cors } from "hono/cors";
|
|
19
20
|
import { logger } from "hono/logger";
|
|
20
|
-
import { existsSync as
|
|
21
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync2 } from "fs";
|
|
22
|
+
import { resolve as resolve6, dirname as dirname4, join as join3 } from "path";
|
|
23
|
+
import { spawn } from "child_process";
|
|
24
|
+
import { createServer as createNetServer } from "net";
|
|
25
|
+
import { fileURLToPath } from "url";
|
|
21
26
|
|
|
22
27
|
// src/server/routes/sessions.ts
|
|
23
28
|
import { Hono } from "hono";
|
|
24
29
|
import { zValidator } from "@hono/zod-validator";
|
|
25
|
-
import { z as
|
|
30
|
+
import { z as z8 } from "zod";
|
|
26
31
|
|
|
27
32
|
// src/db/index.ts
|
|
28
33
|
import Database from "better-sqlite3";
|
|
@@ -33,6 +38,9 @@ import { nanoid } from "nanoid";
|
|
|
33
38
|
// src/db/schema.ts
|
|
34
39
|
var schema_exports = {};
|
|
35
40
|
__export(schema_exports, {
|
|
41
|
+
activeStreams: () => activeStreams,
|
|
42
|
+
checkpoints: () => checkpoints,
|
|
43
|
+
fileBackups: () => fileBackups,
|
|
36
44
|
loadedSkills: () => loadedSkills,
|
|
37
45
|
messages: () => messages,
|
|
38
46
|
sessions: () => sessions,
|
|
@@ -108,6 +116,37 @@ var terminals = sqliteTable("terminals", {
|
|
|
108
116
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
|
|
109
117
|
stoppedAt: integer("stopped_at", { mode: "timestamp" })
|
|
110
118
|
});
|
|
119
|
+
var activeStreams = sqliteTable("active_streams", {
|
|
120
|
+
id: text("id").primaryKey(),
|
|
121
|
+
sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
|
|
122
|
+
streamId: text("stream_id").notNull().unique(),
|
|
123
|
+
// Unique stream identifier
|
|
124
|
+
status: text("status", { enum: ["active", "finished", "error"] }).notNull().default("active"),
|
|
125
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
|
|
126
|
+
finishedAt: integer("finished_at", { mode: "timestamp" })
|
|
127
|
+
});
|
|
128
|
+
var checkpoints = sqliteTable("checkpoints", {
|
|
129
|
+
id: text("id").primaryKey(),
|
|
130
|
+
sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
|
|
131
|
+
// The message sequence number this checkpoint was created BEFORE
|
|
132
|
+
// (i.e., the state before this user message was processed)
|
|
133
|
+
messageSequence: integer("message_sequence").notNull(),
|
|
134
|
+
// Optional git commit hash if in a git repo
|
|
135
|
+
gitHead: text("git_head"),
|
|
136
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
|
|
137
|
+
});
|
|
138
|
+
var fileBackups = sqliteTable("file_backups", {
|
|
139
|
+
id: text("id").primaryKey(),
|
|
140
|
+
checkpointId: text("checkpoint_id").notNull().references(() => checkpoints.id, { onDelete: "cascade" }),
|
|
141
|
+
sessionId: text("session_id").notNull().references(() => sessions.id, { onDelete: "cascade" }),
|
|
142
|
+
// Relative path from working directory
|
|
143
|
+
filePath: text("file_path").notNull(),
|
|
144
|
+
// Original content (null means file didn't exist before)
|
|
145
|
+
originalContent: text("original_content"),
|
|
146
|
+
// Whether the file existed before this checkpoint
|
|
147
|
+
existed: integer("existed", { mode: "boolean" }).notNull().default(true),
|
|
148
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
|
|
149
|
+
});
|
|
111
150
|
|
|
112
151
|
// src/db/index.ts
|
|
113
152
|
var db = null;
|
|
@@ -117,14 +156,7 @@ function initDatabase(dbPath) {
|
|
|
117
156
|
sqlite.pragma("journal_mode = WAL");
|
|
118
157
|
db = drizzle(sqlite, { schema: schema_exports });
|
|
119
158
|
sqlite.exec(`
|
|
120
|
-
|
|
121
|
-
DROP TABLE IF EXISTS loaded_skills;
|
|
122
|
-
DROP TABLE IF EXISTS todo_items;
|
|
123
|
-
DROP TABLE IF EXISTS tool_executions;
|
|
124
|
-
DROP TABLE IF EXISTS messages;
|
|
125
|
-
DROP TABLE IF EXISTS sessions;
|
|
126
|
-
|
|
127
|
-
CREATE TABLE sessions (
|
|
159
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
128
160
|
id TEXT PRIMARY KEY,
|
|
129
161
|
name TEXT,
|
|
130
162
|
working_directory TEXT NOT NULL,
|
|
@@ -135,7 +167,7 @@ function initDatabase(dbPath) {
|
|
|
135
167
|
updated_at INTEGER NOT NULL
|
|
136
168
|
);
|
|
137
169
|
|
|
138
|
-
CREATE TABLE messages (
|
|
170
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
139
171
|
id TEXT PRIMARY KEY,
|
|
140
172
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
141
173
|
model_message TEXT NOT NULL,
|
|
@@ -143,7 +175,7 @@ function initDatabase(dbPath) {
|
|
|
143
175
|
created_at INTEGER NOT NULL
|
|
144
176
|
);
|
|
145
177
|
|
|
146
|
-
CREATE TABLE tool_executions (
|
|
178
|
+
CREATE TABLE IF NOT EXISTS tool_executions (
|
|
147
179
|
id TEXT PRIMARY KEY,
|
|
148
180
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
149
181
|
message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
|
|
@@ -158,7 +190,7 @@ function initDatabase(dbPath) {
|
|
|
158
190
|
completed_at INTEGER
|
|
159
191
|
);
|
|
160
192
|
|
|
161
|
-
CREATE TABLE todo_items (
|
|
193
|
+
CREATE TABLE IF NOT EXISTS todo_items (
|
|
162
194
|
id TEXT PRIMARY KEY,
|
|
163
195
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
164
196
|
content TEXT NOT NULL,
|
|
@@ -168,14 +200,14 @@ function initDatabase(dbPath) {
|
|
|
168
200
|
updated_at INTEGER NOT NULL
|
|
169
201
|
);
|
|
170
202
|
|
|
171
|
-
CREATE TABLE loaded_skills (
|
|
203
|
+
CREATE TABLE IF NOT EXISTS loaded_skills (
|
|
172
204
|
id TEXT PRIMARY KEY,
|
|
173
205
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
174
206
|
skill_name TEXT NOT NULL,
|
|
175
207
|
loaded_at INTEGER NOT NULL
|
|
176
208
|
);
|
|
177
209
|
|
|
178
|
-
CREATE TABLE terminals (
|
|
210
|
+
CREATE TABLE IF NOT EXISTS terminals (
|
|
179
211
|
id TEXT PRIMARY KEY,
|
|
180
212
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
181
213
|
name TEXT,
|
|
@@ -189,11 +221,45 @@ function initDatabase(dbPath) {
|
|
|
189
221
|
stopped_at INTEGER
|
|
190
222
|
);
|
|
191
223
|
|
|
224
|
+
-- Table for tracking active streams (for resumable streams)
|
|
225
|
+
CREATE TABLE IF NOT EXISTS active_streams (
|
|
226
|
+
id TEXT PRIMARY KEY,
|
|
227
|
+
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
228
|
+
stream_id TEXT NOT NULL UNIQUE,
|
|
229
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
230
|
+
created_at INTEGER NOT NULL,
|
|
231
|
+
finished_at INTEGER
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
-- Checkpoints table - created before each user message
|
|
235
|
+
CREATE TABLE IF NOT EXISTS checkpoints (
|
|
236
|
+
id TEXT PRIMARY KEY,
|
|
237
|
+
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
238
|
+
message_sequence INTEGER NOT NULL,
|
|
239
|
+
git_head TEXT,
|
|
240
|
+
created_at INTEGER NOT NULL
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
-- File backups table - stores original file content
|
|
244
|
+
CREATE TABLE IF NOT EXISTS file_backups (
|
|
245
|
+
id TEXT PRIMARY KEY,
|
|
246
|
+
checkpoint_id TEXT NOT NULL REFERENCES checkpoints(id) ON DELETE CASCADE,
|
|
247
|
+
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
248
|
+
file_path TEXT NOT NULL,
|
|
249
|
+
original_content TEXT,
|
|
250
|
+
existed INTEGER NOT NULL DEFAULT 1,
|
|
251
|
+
created_at INTEGER NOT NULL
|
|
252
|
+
);
|
|
253
|
+
|
|
192
254
|
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
|
|
193
255
|
CREATE INDEX IF NOT EXISTS idx_tool_executions_session ON tool_executions(session_id);
|
|
194
256
|
CREATE INDEX IF NOT EXISTS idx_todo_items_session ON todo_items(session_id);
|
|
195
257
|
CREATE INDEX IF NOT EXISTS idx_loaded_skills_session ON loaded_skills(session_id);
|
|
196
258
|
CREATE INDEX IF NOT EXISTS idx_terminals_session ON terminals(session_id);
|
|
259
|
+
CREATE INDEX IF NOT EXISTS idx_active_streams_session ON active_streams(session_id);
|
|
260
|
+
CREATE INDEX IF NOT EXISTS idx_checkpoints_session ON checkpoints(session_id);
|
|
261
|
+
CREATE INDEX IF NOT EXISTS idx_file_backups_checkpoint ON file_backups(checkpoint_id);
|
|
262
|
+
CREATE INDEX IF NOT EXISTS idx_file_backups_session ON file_backups(session_id);
|
|
197
263
|
`);
|
|
198
264
|
return db;
|
|
199
265
|
}
|
|
@@ -203,6 +269,13 @@ function getDb() {
|
|
|
203
269
|
}
|
|
204
270
|
return db;
|
|
205
271
|
}
|
|
272
|
+
function closeDatabase() {
|
|
273
|
+
if (sqlite) {
|
|
274
|
+
sqlite.close();
|
|
275
|
+
sqlite = null;
|
|
276
|
+
db = null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
206
279
|
var sessionQueries = {
|
|
207
280
|
create(data) {
|
|
208
281
|
const id = nanoid();
|
|
@@ -224,6 +297,12 @@ var sessionQueries = {
|
|
|
224
297
|
updateStatus(id, status) {
|
|
225
298
|
return getDb().update(sessions).set({ status, updatedAt: /* @__PURE__ */ new Date() }).where(eq(sessions.id, id)).returning().get();
|
|
226
299
|
},
|
|
300
|
+
updateModel(id, model) {
|
|
301
|
+
return getDb().update(sessions).set({ model, updatedAt: /* @__PURE__ */ new Date() }).where(eq(sessions.id, id)).returning().get();
|
|
302
|
+
},
|
|
303
|
+
update(id, updates) {
|
|
304
|
+
return getDb().update(sessions).set({ ...updates, updatedAt: /* @__PURE__ */ new Date() }).where(eq(sessions.id, id)).returning().get();
|
|
305
|
+
},
|
|
227
306
|
delete(id) {
|
|
228
307
|
const result = getDb().delete(sessions).where(eq(sessions.id, id)).run();
|
|
229
308
|
return result.changes > 0;
|
|
@@ -297,6 +376,19 @@ var messageQueries = {
|
|
|
297
376
|
deleteBySession(sessionId) {
|
|
298
377
|
const result = getDb().delete(messages).where(eq(messages.sessionId, sessionId)).run();
|
|
299
378
|
return result.changes;
|
|
379
|
+
},
|
|
380
|
+
/**
|
|
381
|
+
* Delete all messages with sequence >= the given sequence number
|
|
382
|
+
* (Used when reverting to a checkpoint)
|
|
383
|
+
*/
|
|
384
|
+
deleteFromSequence(sessionId, fromSequence) {
|
|
385
|
+
const result = getDb().delete(messages).where(
|
|
386
|
+
and(
|
|
387
|
+
eq(messages.sessionId, sessionId),
|
|
388
|
+
sql`sequence >= ${fromSequence}`
|
|
389
|
+
)
|
|
390
|
+
).run();
|
|
391
|
+
return result.changes;
|
|
300
392
|
}
|
|
301
393
|
};
|
|
302
394
|
var toolExecutionQueries = {
|
|
@@ -340,6 +432,19 @@ var toolExecutionQueries = {
|
|
|
340
432
|
},
|
|
341
433
|
getBySession(sessionId) {
|
|
342
434
|
return getDb().select().from(toolExecutions).where(eq(toolExecutions.sessionId, sessionId)).orderBy(toolExecutions.startedAt).all();
|
|
435
|
+
},
|
|
436
|
+
/**
|
|
437
|
+
* Delete all tool executions after a given timestamp
|
|
438
|
+
* (Used when reverting to a checkpoint)
|
|
439
|
+
*/
|
|
440
|
+
deleteAfterTime(sessionId, afterTime) {
|
|
441
|
+
const result = getDb().delete(toolExecutions).where(
|
|
442
|
+
and(
|
|
443
|
+
eq(toolExecutions.sessionId, sessionId),
|
|
444
|
+
sql`started_at > ${afterTime.getTime()}`
|
|
445
|
+
)
|
|
446
|
+
).run();
|
|
447
|
+
return result.changes;
|
|
343
448
|
}
|
|
344
449
|
};
|
|
345
450
|
var todoQueries = {
|
|
@@ -405,47 +510,146 @@ var skillQueries = {
|
|
|
405
510
|
return !!result;
|
|
406
511
|
}
|
|
407
512
|
};
|
|
408
|
-
var
|
|
513
|
+
var activeStreamQueries = {
|
|
514
|
+
create(sessionId, streamId) {
|
|
515
|
+
const id = nanoid();
|
|
516
|
+
const result = getDb().insert(activeStreams).values({
|
|
517
|
+
id,
|
|
518
|
+
sessionId,
|
|
519
|
+
streamId,
|
|
520
|
+
status: "active",
|
|
521
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
522
|
+
}).returning().get();
|
|
523
|
+
return result;
|
|
524
|
+
},
|
|
525
|
+
getBySessionId(sessionId) {
|
|
526
|
+
return getDb().select().from(activeStreams).where(
|
|
527
|
+
and(
|
|
528
|
+
eq(activeStreams.sessionId, sessionId),
|
|
529
|
+
eq(activeStreams.status, "active")
|
|
530
|
+
)
|
|
531
|
+
).get();
|
|
532
|
+
},
|
|
533
|
+
getByStreamId(streamId) {
|
|
534
|
+
return getDb().select().from(activeStreams).where(eq(activeStreams.streamId, streamId)).get();
|
|
535
|
+
},
|
|
536
|
+
finish(streamId) {
|
|
537
|
+
return getDb().update(activeStreams).set({ status: "finished", finishedAt: /* @__PURE__ */ new Date() }).where(eq(activeStreams.streamId, streamId)).returning().get();
|
|
538
|
+
},
|
|
539
|
+
markError(streamId) {
|
|
540
|
+
return getDb().update(activeStreams).set({ status: "error", finishedAt: /* @__PURE__ */ new Date() }).where(eq(activeStreams.streamId, streamId)).returning().get();
|
|
541
|
+
},
|
|
542
|
+
deleteBySession(sessionId) {
|
|
543
|
+
const result = getDb().delete(activeStreams).where(eq(activeStreams.sessionId, sessionId)).run();
|
|
544
|
+
return result.changes;
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
var checkpointQueries = {
|
|
409
548
|
create(data) {
|
|
410
549
|
const id = nanoid();
|
|
411
|
-
const result = getDb().insert(
|
|
550
|
+
const result = getDb().insert(checkpoints).values({
|
|
412
551
|
id,
|
|
413
|
-
|
|
552
|
+
sessionId: data.sessionId,
|
|
553
|
+
messageSequence: data.messageSequence,
|
|
554
|
+
gitHead: data.gitHead,
|
|
414
555
|
createdAt: /* @__PURE__ */ new Date()
|
|
415
556
|
}).returning().get();
|
|
416
557
|
return result;
|
|
417
558
|
},
|
|
418
559
|
getById(id) {
|
|
419
|
-
return getDb().select().from(
|
|
560
|
+
return getDb().select().from(checkpoints).where(eq(checkpoints.id, id)).get();
|
|
420
561
|
},
|
|
421
562
|
getBySession(sessionId) {
|
|
422
|
-
return getDb().select().from(
|
|
563
|
+
return getDb().select().from(checkpoints).where(eq(checkpoints.sessionId, sessionId)).orderBy(checkpoints.messageSequence).all();
|
|
423
564
|
},
|
|
424
|
-
|
|
425
|
-
return getDb().select().from(
|
|
565
|
+
getByMessageSequence(sessionId, messageSequence) {
|
|
566
|
+
return getDb().select().from(checkpoints).where(
|
|
426
567
|
and(
|
|
427
|
-
eq(
|
|
428
|
-
eq(
|
|
568
|
+
eq(checkpoints.sessionId, sessionId),
|
|
569
|
+
eq(checkpoints.messageSequence, messageSequence)
|
|
429
570
|
)
|
|
430
|
-
).
|
|
571
|
+
).get();
|
|
431
572
|
},
|
|
432
|
-
|
|
433
|
-
return getDb().
|
|
434
|
-
status,
|
|
435
|
-
exitCode,
|
|
436
|
-
error,
|
|
437
|
-
stoppedAt: status !== "running" ? /* @__PURE__ */ new Date() : void 0
|
|
438
|
-
}).where(eq(terminals.id, id)).returning().get();
|
|
573
|
+
getLatest(sessionId) {
|
|
574
|
+
return getDb().select().from(checkpoints).where(eq(checkpoints.sessionId, sessionId)).orderBy(desc(checkpoints.messageSequence)).limit(1).get();
|
|
439
575
|
},
|
|
440
|
-
|
|
441
|
-
|
|
576
|
+
/**
|
|
577
|
+
* Delete all checkpoints after a given sequence number
|
|
578
|
+
* (Used when reverting to a checkpoint)
|
|
579
|
+
*/
|
|
580
|
+
deleteAfterSequence(sessionId, messageSequence) {
|
|
581
|
+
const result = getDb().delete(checkpoints).where(
|
|
582
|
+
and(
|
|
583
|
+
eq(checkpoints.sessionId, sessionId),
|
|
584
|
+
sql`message_sequence > ${messageSequence}`
|
|
585
|
+
)
|
|
586
|
+
).run();
|
|
587
|
+
return result.changes;
|
|
442
588
|
},
|
|
443
|
-
|
|
444
|
-
const result = getDb().delete(
|
|
445
|
-
return result.changes
|
|
589
|
+
deleteBySession(sessionId) {
|
|
590
|
+
const result = getDb().delete(checkpoints).where(eq(checkpoints.sessionId, sessionId)).run();
|
|
591
|
+
return result.changes;
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
var fileBackupQueries = {
|
|
595
|
+
create(data) {
|
|
596
|
+
const id = nanoid();
|
|
597
|
+
const result = getDb().insert(fileBackups).values({
|
|
598
|
+
id,
|
|
599
|
+
checkpointId: data.checkpointId,
|
|
600
|
+
sessionId: data.sessionId,
|
|
601
|
+
filePath: data.filePath,
|
|
602
|
+
originalContent: data.originalContent,
|
|
603
|
+
existed: data.existed,
|
|
604
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
605
|
+
}).returning().get();
|
|
606
|
+
return result;
|
|
607
|
+
},
|
|
608
|
+
getByCheckpoint(checkpointId) {
|
|
609
|
+
return getDb().select().from(fileBackups).where(eq(fileBackups.checkpointId, checkpointId)).all();
|
|
610
|
+
},
|
|
611
|
+
getBySession(sessionId) {
|
|
612
|
+
return getDb().select().from(fileBackups).where(eq(fileBackups.sessionId, sessionId)).orderBy(fileBackups.createdAt).all();
|
|
613
|
+
},
|
|
614
|
+
/**
|
|
615
|
+
* Get all file backups from a given checkpoint sequence onwards (inclusive)
|
|
616
|
+
* (Used when reverting - need to restore these files)
|
|
617
|
+
*
|
|
618
|
+
* When reverting to checkpoint X, we need backups from checkpoint X and all later ones
|
|
619
|
+
* because checkpoint X's backups represent the state BEFORE processing message X.
|
|
620
|
+
*/
|
|
621
|
+
getFromSequence(sessionId, messageSequence) {
|
|
622
|
+
const checkpointsFrom = getDb().select().from(checkpoints).where(
|
|
623
|
+
and(
|
|
624
|
+
eq(checkpoints.sessionId, sessionId),
|
|
625
|
+
sql`message_sequence >= ${messageSequence}`
|
|
626
|
+
)
|
|
627
|
+
).all();
|
|
628
|
+
if (checkpointsFrom.length === 0) {
|
|
629
|
+
return [];
|
|
630
|
+
}
|
|
631
|
+
const checkpointIds = checkpointsFrom.map((c) => c.id);
|
|
632
|
+
const allBackups = [];
|
|
633
|
+
for (const cpId of checkpointIds) {
|
|
634
|
+
const backups = getDb().select().from(fileBackups).where(eq(fileBackups.checkpointId, cpId)).all();
|
|
635
|
+
allBackups.push(...backups);
|
|
636
|
+
}
|
|
637
|
+
return allBackups;
|
|
638
|
+
},
|
|
639
|
+
/**
|
|
640
|
+
* Check if a file already has a backup in the current checkpoint
|
|
641
|
+
*/
|
|
642
|
+
hasBackup(checkpointId, filePath) {
|
|
643
|
+
const result = getDb().select().from(fileBackups).where(
|
|
644
|
+
and(
|
|
645
|
+
eq(fileBackups.checkpointId, checkpointId),
|
|
646
|
+
eq(fileBackups.filePath, filePath)
|
|
647
|
+
)
|
|
648
|
+
).get();
|
|
649
|
+
return !!result;
|
|
446
650
|
},
|
|
447
651
|
deleteBySession(sessionId) {
|
|
448
|
-
const result = getDb().delete(
|
|
652
|
+
const result = getDb().delete(fileBackups).where(eq(fileBackups.sessionId, sessionId)).run();
|
|
449
653
|
return result.changes;
|
|
450
654
|
}
|
|
451
655
|
};
|
|
@@ -454,16 +658,17 @@ var terminalQueries = {
|
|
|
454
658
|
import {
|
|
455
659
|
streamText,
|
|
456
660
|
generateText as generateText2,
|
|
457
|
-
tool as
|
|
661
|
+
tool as tool6,
|
|
458
662
|
stepCountIs
|
|
459
663
|
} from "ai";
|
|
460
664
|
import { gateway as gateway2 } from "@ai-sdk/gateway";
|
|
461
|
-
import { z as
|
|
462
|
-
import { nanoid as
|
|
665
|
+
import { z as z7 } from "zod";
|
|
666
|
+
import { nanoid as nanoid3 } from "nanoid";
|
|
463
667
|
|
|
464
668
|
// src/config/index.ts
|
|
465
|
-
import { existsSync, readFileSync } from "fs";
|
|
466
|
-
import { resolve, dirname } from "path";
|
|
669
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from "fs";
|
|
670
|
+
import { resolve, dirname, join } from "path";
|
|
671
|
+
import { homedir, platform } from "os";
|
|
467
672
|
|
|
468
673
|
// src/config/types.ts
|
|
469
674
|
import { z } from "zod";
|
|
@@ -524,6 +729,24 @@ var CONFIG_FILE_NAMES = [
|
|
|
524
729
|
"sparkecoder.json",
|
|
525
730
|
".sparkecoder.json"
|
|
526
731
|
];
|
|
732
|
+
function getAppDataDirectory() {
|
|
733
|
+
const appName = "sparkecoder";
|
|
734
|
+
switch (platform()) {
|
|
735
|
+
case "darwin":
|
|
736
|
+
return join(homedir(), "Library", "Application Support", appName);
|
|
737
|
+
case "win32":
|
|
738
|
+
return join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), appName);
|
|
739
|
+
default:
|
|
740
|
+
return join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), appName);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
function ensureAppDataDirectory() {
|
|
744
|
+
const dir = getAppDataDirectory();
|
|
745
|
+
if (!existsSync(dir)) {
|
|
746
|
+
mkdirSync(dir, { recursive: true });
|
|
747
|
+
}
|
|
748
|
+
return dir;
|
|
749
|
+
}
|
|
527
750
|
var cachedConfig = null;
|
|
528
751
|
function findConfigFile(startDir) {
|
|
529
752
|
let currentDir = startDir;
|
|
@@ -536,6 +759,13 @@ function findConfigFile(startDir) {
|
|
|
536
759
|
}
|
|
537
760
|
currentDir = dirname(currentDir);
|
|
538
761
|
}
|
|
762
|
+
const appDataDir = getAppDataDirectory();
|
|
763
|
+
for (const fileName of CONFIG_FILE_NAMES) {
|
|
764
|
+
const configPath = join(appDataDir, fileName);
|
|
765
|
+
if (existsSync(configPath)) {
|
|
766
|
+
return configPath;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
539
769
|
return null;
|
|
540
770
|
}
|
|
541
771
|
function loadConfig(configPath, workingDirectory) {
|
|
@@ -570,7 +800,14 @@ function loadConfig(configPath, workingDirectory) {
|
|
|
570
800
|
rawConfig.databasePath = process.env.DATABASE_PATH;
|
|
571
801
|
}
|
|
572
802
|
const config = SparkcoderConfigSchema.parse(rawConfig);
|
|
573
|
-
|
|
803
|
+
let resolvedWorkingDirectory;
|
|
804
|
+
if (workingDirectory) {
|
|
805
|
+
resolvedWorkingDirectory = workingDirectory;
|
|
806
|
+
} else if (config.workingDirectory && config.workingDirectory !== "." && config.workingDirectory.startsWith("/")) {
|
|
807
|
+
resolvedWorkingDirectory = config.workingDirectory;
|
|
808
|
+
} else {
|
|
809
|
+
resolvedWorkingDirectory = process.cwd();
|
|
810
|
+
}
|
|
574
811
|
const resolvedSkillsDirectories = [
|
|
575
812
|
resolve(configDir, config.skills?.directory || "./skills"),
|
|
576
813
|
// Built-in skills
|
|
@@ -585,7 +822,13 @@ function loadConfig(configPath, workingDirectory) {
|
|
|
585
822
|
return false;
|
|
586
823
|
}
|
|
587
824
|
});
|
|
588
|
-
|
|
825
|
+
let resolvedDatabasePath;
|
|
826
|
+
if (config.databasePath && config.databasePath !== "./sparkecoder.db") {
|
|
827
|
+
resolvedDatabasePath = resolve(configDir, config.databasePath);
|
|
828
|
+
} else {
|
|
829
|
+
const appDataDir = ensureAppDataDirectory();
|
|
830
|
+
resolvedDatabasePath = join(appDataDir, "sparkecoder.db");
|
|
831
|
+
}
|
|
589
832
|
const resolved = {
|
|
590
833
|
...config,
|
|
591
834
|
server: {
|
|
@@ -622,7 +865,7 @@ function requiresApproval(toolName, sessionConfig) {
|
|
|
622
865
|
function createDefaultConfig() {
|
|
623
866
|
return {
|
|
624
867
|
defaultModel: "anthropic/claude-opus-4-5",
|
|
625
|
-
workingDirectory
|
|
868
|
+
// workingDirectory is intentionally not set - defaults to where CLI is run
|
|
626
869
|
toolApprovals: {
|
|
627
870
|
bash: true,
|
|
628
871
|
write_file: false,
|
|
@@ -646,12 +889,104 @@ function createDefaultConfig() {
|
|
|
646
889
|
databasePath: "./sparkecoder.db"
|
|
647
890
|
};
|
|
648
891
|
}
|
|
892
|
+
var API_KEYS_FILE = "api-keys.json";
|
|
893
|
+
var PROVIDER_ENV_MAP = {
|
|
894
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
895
|
+
openai: "OPENAI_API_KEY",
|
|
896
|
+
google: "GOOGLE_GENERATIVE_AI_API_KEY",
|
|
897
|
+
xai: "XAI_API_KEY",
|
|
898
|
+
"ai-gateway": "AI_GATEWAY_API_KEY"
|
|
899
|
+
};
|
|
900
|
+
var SUPPORTED_PROVIDERS = Object.keys(PROVIDER_ENV_MAP);
|
|
901
|
+
function getApiKeysPath() {
|
|
902
|
+
const appDir = ensureAppDataDirectory();
|
|
903
|
+
return join(appDir, API_KEYS_FILE);
|
|
904
|
+
}
|
|
905
|
+
function loadStoredApiKeys() {
|
|
906
|
+
const keysPath = getApiKeysPath();
|
|
907
|
+
if (!existsSync(keysPath)) {
|
|
908
|
+
return {};
|
|
909
|
+
}
|
|
910
|
+
try {
|
|
911
|
+
const content = readFileSync(keysPath, "utf-8");
|
|
912
|
+
return JSON.parse(content);
|
|
913
|
+
} catch {
|
|
914
|
+
return {};
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
function saveStoredApiKeys(keys) {
|
|
918
|
+
const keysPath = getApiKeysPath();
|
|
919
|
+
writeFileSync(keysPath, JSON.stringify(keys, null, 2), { mode: 384 });
|
|
920
|
+
}
|
|
921
|
+
function loadApiKeysIntoEnv() {
|
|
922
|
+
const storedKeys = loadStoredApiKeys();
|
|
923
|
+
for (const [provider, envVar] of Object.entries(PROVIDER_ENV_MAP)) {
|
|
924
|
+
if (!process.env[envVar] && storedKeys[provider]) {
|
|
925
|
+
process.env[envVar] = storedKeys[provider];
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
function setApiKey(provider, apiKey) {
|
|
930
|
+
const normalizedProvider = provider.toLowerCase();
|
|
931
|
+
const envVar = PROVIDER_ENV_MAP[normalizedProvider];
|
|
932
|
+
if (!envVar) {
|
|
933
|
+
throw new Error(`Unknown provider: ${provider}. Supported: ${SUPPORTED_PROVIDERS.join(", ")}`);
|
|
934
|
+
}
|
|
935
|
+
const storedKeys = loadStoredApiKeys();
|
|
936
|
+
storedKeys[normalizedProvider] = apiKey;
|
|
937
|
+
saveStoredApiKeys(storedKeys);
|
|
938
|
+
process.env[envVar] = apiKey;
|
|
939
|
+
}
|
|
940
|
+
function removeApiKey(provider) {
|
|
941
|
+
const normalizedProvider = provider.toLowerCase();
|
|
942
|
+
const envVar = PROVIDER_ENV_MAP[normalizedProvider];
|
|
943
|
+
if (!envVar) {
|
|
944
|
+
throw new Error(`Unknown provider: ${provider}. Supported: ${SUPPORTED_PROVIDERS.join(", ")}`);
|
|
945
|
+
}
|
|
946
|
+
const storedKeys = loadStoredApiKeys();
|
|
947
|
+
delete storedKeys[normalizedProvider];
|
|
948
|
+
saveStoredApiKeys(storedKeys);
|
|
949
|
+
}
|
|
950
|
+
function getApiKeyStatus() {
|
|
951
|
+
const storedKeys = loadStoredApiKeys();
|
|
952
|
+
return SUPPORTED_PROVIDERS.map((provider) => {
|
|
953
|
+
const envVar = PROVIDER_ENV_MAP[provider];
|
|
954
|
+
const envValue = process.env[envVar];
|
|
955
|
+
const storedValue = storedKeys[provider];
|
|
956
|
+
let source = "none";
|
|
957
|
+
let value;
|
|
958
|
+
if (envValue) {
|
|
959
|
+
if (storedValue && envValue === storedValue) {
|
|
960
|
+
source = "storage";
|
|
961
|
+
} else {
|
|
962
|
+
source = "env";
|
|
963
|
+
}
|
|
964
|
+
value = envValue;
|
|
965
|
+
} else if (storedValue) {
|
|
966
|
+
source = "storage";
|
|
967
|
+
value = storedValue;
|
|
968
|
+
}
|
|
969
|
+
return {
|
|
970
|
+
provider,
|
|
971
|
+
envVar,
|
|
972
|
+
configured: !!value,
|
|
973
|
+
source,
|
|
974
|
+
maskedKey: value ? maskApiKey(value) : null
|
|
975
|
+
};
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
function maskApiKey(key) {
|
|
979
|
+
if (key.length <= 12) {
|
|
980
|
+
return "****" + key.slice(-4);
|
|
981
|
+
}
|
|
982
|
+
return key.slice(0, 4) + "..." + key.slice(-4);
|
|
983
|
+
}
|
|
649
984
|
|
|
650
985
|
// src/tools/bash.ts
|
|
651
986
|
import { tool } from "ai";
|
|
652
987
|
import { z as z2 } from "zod";
|
|
653
|
-
import { exec } from "child_process";
|
|
654
|
-
import { promisify } from "util";
|
|
988
|
+
import { exec as exec2 } from "child_process";
|
|
989
|
+
import { promisify as promisify2 } from "util";
|
|
655
990
|
|
|
656
991
|
// src/utils/truncate.ts
|
|
657
992
|
var MAX_OUTPUT_CHARS = 1e4;
|
|
@@ -674,109 +1009,561 @@ function calculateContextSize(messages2) {
|
|
|
674
1009
|
}, 0);
|
|
675
1010
|
}
|
|
676
1011
|
|
|
677
|
-
// src/
|
|
1012
|
+
// src/terminal/tmux.ts
|
|
1013
|
+
import { exec } from "child_process";
|
|
1014
|
+
import { promisify } from "util";
|
|
1015
|
+
import { mkdir, writeFile, readFile } from "fs/promises";
|
|
1016
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1017
|
+
import { join as join2 } from "path";
|
|
1018
|
+
import { nanoid as nanoid2 } from "nanoid";
|
|
678
1019
|
var execAsync = promisify(exec);
|
|
679
|
-
var
|
|
680
|
-
var
|
|
681
|
-
var
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
(
|
|
693
|
-
|
|
1020
|
+
var SESSION_PREFIX = "spark_";
|
|
1021
|
+
var LOG_BASE_DIR = ".sparkecoder/sessions";
|
|
1022
|
+
var tmuxAvailableCache = null;
|
|
1023
|
+
async function isTmuxAvailable() {
|
|
1024
|
+
if (tmuxAvailableCache !== null) {
|
|
1025
|
+
return tmuxAvailableCache;
|
|
1026
|
+
}
|
|
1027
|
+
try {
|
|
1028
|
+
const { stdout } = await execAsync("tmux -V");
|
|
1029
|
+
tmuxAvailableCache = true;
|
|
1030
|
+
return true;
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
tmuxAvailableCache = false;
|
|
1033
|
+
console.log(`[tmux] Not available: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
1034
|
+
return false;
|
|
1035
|
+
}
|
|
694
1036
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
}
|
|
698
|
-
function
|
|
699
|
-
return
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
1037
|
+
function generateTerminalId() {
|
|
1038
|
+
return "t" + nanoid2(9);
|
|
1039
|
+
}
|
|
1040
|
+
function getSessionName(terminalId) {
|
|
1041
|
+
return `${SESSION_PREFIX}${terminalId}`;
|
|
1042
|
+
}
|
|
1043
|
+
function getLogDir(terminalId, workingDirectory, sessionId) {
|
|
1044
|
+
if (sessionId) {
|
|
1045
|
+
return join2(workingDirectory, LOG_BASE_DIR, sessionId, "terminals", terminalId);
|
|
1046
|
+
}
|
|
1047
|
+
return join2(workingDirectory, ".sparkecoder/terminals", terminalId);
|
|
1048
|
+
}
|
|
1049
|
+
function shellEscape(str) {
|
|
1050
|
+
return `'${str.replace(/'/g, "'\\''")}'`;
|
|
1051
|
+
}
|
|
1052
|
+
async function initLogDir(terminalId, meta, workingDirectory) {
|
|
1053
|
+
const logDir = getLogDir(terminalId, workingDirectory, meta.sessionId);
|
|
1054
|
+
await mkdir(logDir, { recursive: true });
|
|
1055
|
+
await writeFile(join2(logDir, "meta.json"), JSON.stringify(meta, null, 2));
|
|
1056
|
+
await writeFile(join2(logDir, "output.log"), "");
|
|
1057
|
+
return logDir;
|
|
1058
|
+
}
|
|
1059
|
+
async function pollUntil(condition, options) {
|
|
1060
|
+
const { timeout, interval = 100 } = options;
|
|
1061
|
+
const startTime = Date.now();
|
|
1062
|
+
while (Date.now() - startTime < timeout) {
|
|
1063
|
+
if (await condition()) {
|
|
1064
|
+
return true;
|
|
1065
|
+
}
|
|
1066
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
1067
|
+
}
|
|
1068
|
+
return false;
|
|
1069
|
+
}
|
|
1070
|
+
async function runSync(command, workingDirectory, options) {
|
|
1071
|
+
if (!options) {
|
|
1072
|
+
throw new Error("runSync: options parameter is required (must include sessionId)");
|
|
1073
|
+
}
|
|
1074
|
+
const id = options.terminalId || generateTerminalId();
|
|
1075
|
+
const session = getSessionName(id);
|
|
1076
|
+
const logDir = await initLogDir(id, {
|
|
1077
|
+
id,
|
|
1078
|
+
command,
|
|
1079
|
+
cwd: workingDirectory,
|
|
1080
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1081
|
+
sessionId: options.sessionId,
|
|
1082
|
+
background: false
|
|
1083
|
+
}, workingDirectory);
|
|
1084
|
+
const logFile = join2(logDir, "output.log");
|
|
1085
|
+
const exitCodeFile = join2(logDir, "exit_code");
|
|
1086
|
+
const timeout = options.timeout || 12e4;
|
|
1087
|
+
try {
|
|
1088
|
+
const wrappedCommand = `(${command}) 2>&1 | tee -a ${shellEscape(logFile)}; echo $? > ${shellEscape(exitCodeFile)}`;
|
|
1089
|
+
await execAsync(
|
|
1090
|
+
`tmux new-session -d -s ${session} -c ${shellEscape(workingDirectory)} ${shellEscape(wrappedCommand)}`,
|
|
1091
|
+
{ timeout: 5e3 }
|
|
1092
|
+
);
|
|
1093
|
+
try {
|
|
1094
|
+
await execAsync(
|
|
1095
|
+
`tmux pipe-pane -t ${session} -o 'cat >> ${shellEscape(logFile)}'`,
|
|
1096
|
+
{ timeout: 1e3 }
|
|
1097
|
+
);
|
|
1098
|
+
} catch {
|
|
1099
|
+
}
|
|
1100
|
+
const completed = await pollUntil(
|
|
1101
|
+
async () => {
|
|
1102
|
+
try {
|
|
1103
|
+
await execAsync(`tmux has-session -t ${session}`, { timeout: 1e3 });
|
|
1104
|
+
return false;
|
|
1105
|
+
} catch {
|
|
1106
|
+
return true;
|
|
1107
|
+
}
|
|
1108
|
+
},
|
|
1109
|
+
{ timeout, interval: 100 }
|
|
1110
|
+
);
|
|
1111
|
+
if (!completed) {
|
|
1112
|
+
try {
|
|
1113
|
+
await execAsync(`tmux kill-session -t ${session}`, { timeout: 5e3 });
|
|
1114
|
+
} catch {
|
|
714
1115
|
}
|
|
1116
|
+
let output2 = "";
|
|
715
1117
|
try {
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
timeout: COMMAND_TIMEOUT,
|
|
719
|
-
maxBuffer: 10 * 1024 * 1024,
|
|
720
|
-
// 10MB buffer
|
|
721
|
-
shell: "/bin/bash"
|
|
722
|
-
});
|
|
723
|
-
const truncatedStdout = truncateOutput(stdout, MAX_OUTPUT_CHARS2);
|
|
724
|
-
const truncatedStderr = truncateOutput(stderr, MAX_OUTPUT_CHARS2 / 2);
|
|
725
|
-
if (options.onOutput) {
|
|
726
|
-
options.onOutput(truncatedStdout);
|
|
727
|
-
}
|
|
728
|
-
return {
|
|
729
|
-
success: true,
|
|
730
|
-
stdout: truncatedStdout,
|
|
731
|
-
stderr: truncatedStderr,
|
|
732
|
-
exitCode: 0
|
|
733
|
-
};
|
|
734
|
-
} catch (error) {
|
|
735
|
-
const stdout = error.stdout ? truncateOutput(error.stdout, MAX_OUTPUT_CHARS2) : "";
|
|
736
|
-
const stderr = error.stderr ? truncateOutput(error.stderr, MAX_OUTPUT_CHARS2) : "";
|
|
737
|
-
if (options.onOutput) {
|
|
738
|
-
options.onOutput(stderr || error.message);
|
|
739
|
-
}
|
|
740
|
-
if (error.killed) {
|
|
741
|
-
return {
|
|
742
|
-
success: false,
|
|
743
|
-
error: `Command timed out after ${COMMAND_TIMEOUT / 1e3} seconds`,
|
|
744
|
-
stdout,
|
|
745
|
-
stderr,
|
|
746
|
-
exitCode: 124
|
|
747
|
-
// Standard timeout exit code
|
|
748
|
-
};
|
|
749
|
-
}
|
|
750
|
-
return {
|
|
751
|
-
success: false,
|
|
752
|
-
error: error.message,
|
|
753
|
-
stdout,
|
|
754
|
-
stderr,
|
|
755
|
-
exitCode: error.code ?? 1
|
|
756
|
-
};
|
|
1118
|
+
output2 = await readFile(logFile, "utf-8");
|
|
1119
|
+
} catch {
|
|
757
1120
|
}
|
|
1121
|
+
return {
|
|
1122
|
+
id,
|
|
1123
|
+
output: output2.trim(),
|
|
1124
|
+
exitCode: 124,
|
|
1125
|
+
// Standard timeout exit code
|
|
1126
|
+
status: "error"
|
|
1127
|
+
};
|
|
758
1128
|
}
|
|
759
|
-
|
|
1129
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1130
|
+
let output = "";
|
|
1131
|
+
try {
|
|
1132
|
+
output = await readFile(logFile, "utf-8");
|
|
1133
|
+
} catch {
|
|
1134
|
+
}
|
|
1135
|
+
let exitCode = 0;
|
|
1136
|
+
try {
|
|
1137
|
+
if (existsSync2(exitCodeFile)) {
|
|
1138
|
+
const exitCodeStr = await readFile(exitCodeFile, "utf-8");
|
|
1139
|
+
exitCode = parseInt(exitCodeStr.trim(), 10) || 0;
|
|
1140
|
+
}
|
|
1141
|
+
} catch {
|
|
1142
|
+
}
|
|
1143
|
+
return {
|
|
1144
|
+
id,
|
|
1145
|
+
output: output.trim(),
|
|
1146
|
+
exitCode,
|
|
1147
|
+
status: "completed"
|
|
1148
|
+
};
|
|
1149
|
+
} catch (error) {
|
|
1150
|
+
try {
|
|
1151
|
+
await execAsync(`tmux kill-session -t ${session}`, { timeout: 5e3 });
|
|
1152
|
+
} catch {
|
|
1153
|
+
}
|
|
1154
|
+
throw error;
|
|
1155
|
+
}
|
|
760
1156
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
});
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
1157
|
+
async function runBackground(command, workingDirectory, options) {
|
|
1158
|
+
if (!options) {
|
|
1159
|
+
throw new Error("runBackground: options parameter is required (must include sessionId)");
|
|
1160
|
+
}
|
|
1161
|
+
const id = options.terminalId || generateTerminalId();
|
|
1162
|
+
const session = getSessionName(id);
|
|
1163
|
+
const logDir = await initLogDir(id, {
|
|
1164
|
+
id,
|
|
1165
|
+
command,
|
|
1166
|
+
cwd: workingDirectory,
|
|
1167
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1168
|
+
sessionId: options.sessionId,
|
|
1169
|
+
background: true
|
|
1170
|
+
}, workingDirectory);
|
|
1171
|
+
const logFile = join2(logDir, "output.log");
|
|
1172
|
+
const wrappedCommand = `(${command}) 2>&1 | tee -a ${shellEscape(logFile)}`;
|
|
1173
|
+
await execAsync(
|
|
1174
|
+
`tmux new-session -d -s ${session} -c ${shellEscape(workingDirectory)} ${shellEscape(wrappedCommand)}`,
|
|
1175
|
+
{ timeout: 5e3 }
|
|
1176
|
+
);
|
|
1177
|
+
return {
|
|
1178
|
+
id,
|
|
1179
|
+
output: "",
|
|
1180
|
+
exitCode: 0,
|
|
1181
|
+
status: "running"
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
async function getLogs(terminalId, workingDirectory, options = {}) {
|
|
1185
|
+
const session = getSessionName(terminalId);
|
|
1186
|
+
const logDir = getLogDir(terminalId, workingDirectory, options.sessionId);
|
|
1187
|
+
const logFile = join2(logDir, "output.log");
|
|
1188
|
+
let isRunning2 = false;
|
|
1189
|
+
try {
|
|
1190
|
+
await execAsync(`tmux has-session -t ${session}`, { timeout: 5e3 });
|
|
1191
|
+
isRunning2 = true;
|
|
1192
|
+
} catch {
|
|
1193
|
+
}
|
|
1194
|
+
if (isRunning2) {
|
|
1195
|
+
try {
|
|
1196
|
+
const lines = options.tail || 1e3;
|
|
1197
|
+
const { stdout } = await execAsync(
|
|
1198
|
+
`tmux capture-pane -t ${session} -p -S -${lines}`,
|
|
1199
|
+
{ timeout: 5e3, maxBuffer: 10 * 1024 * 1024 }
|
|
1200
|
+
);
|
|
1201
|
+
return { output: stdout.trim(), status: "running" };
|
|
1202
|
+
} catch {
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
try {
|
|
1206
|
+
let output = await readFile(logFile, "utf-8");
|
|
1207
|
+
if (options.tail) {
|
|
1208
|
+
const lines = output.split("\n");
|
|
1209
|
+
output = lines.slice(-options.tail).join("\n");
|
|
1210
|
+
}
|
|
1211
|
+
return { output: output.trim(), status: isRunning2 ? "running" : "stopped" };
|
|
1212
|
+
} catch {
|
|
1213
|
+
return { output: "", status: "unknown" };
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
async function isRunning(terminalId) {
|
|
1217
|
+
const session = getSessionName(terminalId);
|
|
1218
|
+
try {
|
|
1219
|
+
await execAsync(`tmux has-session -t ${session}`, { timeout: 5e3 });
|
|
1220
|
+
return true;
|
|
1221
|
+
} catch {
|
|
1222
|
+
return false;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
async function killTerminal(terminalId) {
|
|
1226
|
+
const session = getSessionName(terminalId);
|
|
1227
|
+
try {
|
|
1228
|
+
await execAsync(`tmux kill-session -t ${session}`, { timeout: 5e3 });
|
|
1229
|
+
return true;
|
|
1230
|
+
} catch {
|
|
1231
|
+
return false;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
async function listSessions() {
|
|
1235
|
+
try {
|
|
1236
|
+
const { stdout } = await execAsync(
|
|
1237
|
+
`tmux list-sessions -F '#{session_name}' 2>/dev/null || true`,
|
|
1238
|
+
{ timeout: 5e3 }
|
|
1239
|
+
);
|
|
1240
|
+
return stdout.trim().split("\n").filter((name) => name.startsWith(SESSION_PREFIX)).map((name) => name.slice(SESSION_PREFIX.length));
|
|
1241
|
+
} catch {
|
|
1242
|
+
return [];
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
async function getMeta(terminalId, workingDirectory, sessionId) {
|
|
1246
|
+
const logDir = getLogDir(terminalId, workingDirectory, sessionId);
|
|
1247
|
+
const metaFile = join2(logDir, "meta.json");
|
|
1248
|
+
try {
|
|
1249
|
+
const content = await readFile(metaFile, "utf-8");
|
|
1250
|
+
return JSON.parse(content);
|
|
1251
|
+
} catch {
|
|
1252
|
+
return null;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
async function listSessionTerminals(sessionId, workingDirectory) {
|
|
1256
|
+
const terminalsDir = join2(workingDirectory, LOG_BASE_DIR, sessionId, "terminals");
|
|
1257
|
+
const terminals3 = [];
|
|
1258
|
+
try {
|
|
1259
|
+
const { readdir: readdir2 } = await import("fs/promises");
|
|
1260
|
+
const entries = await readdir2(terminalsDir, { withFileTypes: true });
|
|
1261
|
+
for (const entry of entries) {
|
|
1262
|
+
if (entry.isDirectory()) {
|
|
1263
|
+
const meta = await getMeta(entry.name, workingDirectory, sessionId);
|
|
1264
|
+
if (meta) {
|
|
1265
|
+
terminals3.push(meta);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
} catch {
|
|
1270
|
+
}
|
|
1271
|
+
return terminals3;
|
|
1272
|
+
}
|
|
1273
|
+
async function sendInput(terminalId, input, options = {}) {
|
|
1274
|
+
const session = getSessionName(terminalId);
|
|
1275
|
+
const { pressEnter = true } = options;
|
|
1276
|
+
try {
|
|
1277
|
+
await execAsync(`tmux has-session -t ${session}`, { timeout: 1e3 });
|
|
1278
|
+
await execAsync(
|
|
1279
|
+
`tmux send-keys -t ${session} -l ${shellEscape(input)}`,
|
|
1280
|
+
{ timeout: 1e3 }
|
|
1281
|
+
);
|
|
1282
|
+
if (pressEnter) {
|
|
1283
|
+
await execAsync(
|
|
1284
|
+
`tmux send-keys -t ${session} Enter`,
|
|
1285
|
+
{ timeout: 1e3 }
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
return true;
|
|
1289
|
+
} catch {
|
|
1290
|
+
return false;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
async function sendKey(terminalId, key) {
|
|
1294
|
+
const session = getSessionName(terminalId);
|
|
1295
|
+
try {
|
|
1296
|
+
await execAsync(`tmux has-session -t ${session}`, { timeout: 1e3 });
|
|
1297
|
+
await execAsync(`tmux send-keys -t ${session} ${key}`, { timeout: 1e3 });
|
|
1298
|
+
return true;
|
|
1299
|
+
} catch {
|
|
1300
|
+
return false;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// src/tools/bash.ts
|
|
1305
|
+
var execAsync2 = promisify2(exec2);
|
|
1306
|
+
var COMMAND_TIMEOUT = 12e4;
|
|
1307
|
+
var MAX_OUTPUT_CHARS2 = 1e4;
|
|
1308
|
+
var BLOCKED_COMMANDS = [
|
|
1309
|
+
"rm -rf /",
|
|
1310
|
+
"rm -rf ~",
|
|
1311
|
+
"mkfs",
|
|
1312
|
+
"dd if=/dev/zero",
|
|
1313
|
+
":(){:|:&};:",
|
|
1314
|
+
"chmod -R 777 /"
|
|
1315
|
+
];
|
|
1316
|
+
function isBlockedCommand(command) {
|
|
1317
|
+
const normalizedCommand = command.toLowerCase().trim();
|
|
1318
|
+
return BLOCKED_COMMANDS.some(
|
|
1319
|
+
(blocked) => normalizedCommand.includes(blocked.toLowerCase())
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
var bashInputSchema = z2.object({
|
|
1323
|
+
command: z2.string().optional().describe("The command to execute. Required for running new commands."),
|
|
1324
|
+
background: z2.boolean().default(false).describe("Run the command in background mode (for dev servers, watchers). Returns immediately with terminal ID."),
|
|
1325
|
+
id: z2.string().optional().describe("Terminal ID. Use to get logs from, send input to, or kill an existing terminal."),
|
|
1326
|
+
kill: z2.boolean().optional().describe("Kill the terminal with the given ID."),
|
|
1327
|
+
tail: z2.number().optional().describe("Number of lines to return from the end of output (for logs)."),
|
|
1328
|
+
input: z2.string().optional().describe("Send text input to an interactive terminal (requires id). Used for responding to prompts."),
|
|
1329
|
+
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.')
|
|
1330
|
+
});
|
|
1331
|
+
var useTmux = null;
|
|
1332
|
+
async function shouldUseTmux() {
|
|
1333
|
+
if (useTmux === null) {
|
|
1334
|
+
useTmux = await isTmuxAvailable();
|
|
1335
|
+
if (!useTmux) {
|
|
1336
|
+
console.warn("[bash] tmux not available, using fallback exec mode");
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
return useTmux;
|
|
1340
|
+
}
|
|
1341
|
+
async function execFallback(command, workingDirectory, onOutput) {
|
|
1342
|
+
try {
|
|
1343
|
+
const { stdout, stderr } = await execAsync2(command, {
|
|
1344
|
+
cwd: workingDirectory,
|
|
1345
|
+
timeout: COMMAND_TIMEOUT,
|
|
1346
|
+
maxBuffer: 10 * 1024 * 1024
|
|
1347
|
+
});
|
|
1348
|
+
const output = truncateOutput(stdout + (stderr ? `
|
|
1349
|
+
${stderr}` : ""), MAX_OUTPUT_CHARS2);
|
|
1350
|
+
onOutput?.(output);
|
|
1351
|
+
return {
|
|
1352
|
+
success: true,
|
|
1353
|
+
output,
|
|
1354
|
+
exitCode: 0
|
|
1355
|
+
};
|
|
1356
|
+
} catch (error) {
|
|
1357
|
+
const output = truncateOutput(
|
|
1358
|
+
(error.stdout || "") + (error.stderr ? `
|
|
1359
|
+
${error.stderr}` : ""),
|
|
1360
|
+
MAX_OUTPUT_CHARS2
|
|
1361
|
+
);
|
|
1362
|
+
onOutput?.(output || error.message);
|
|
1363
|
+
if (error.killed) {
|
|
1364
|
+
return {
|
|
1365
|
+
success: false,
|
|
1366
|
+
error: `Command timed out after ${COMMAND_TIMEOUT / 1e3} seconds`,
|
|
1367
|
+
output,
|
|
1368
|
+
exitCode: 124
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
return {
|
|
1372
|
+
success: false,
|
|
1373
|
+
error: error.message,
|
|
1374
|
+
output,
|
|
1375
|
+
exitCode: error.code ?? 1
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
function createBashTool(options) {
|
|
1380
|
+
return tool({
|
|
1381
|
+
description: `Execute commands in the terminal. Every command runs in its own session with logs saved to disk.
|
|
1382
|
+
|
|
1383
|
+
**Run a command (default - waits for completion):**
|
|
1384
|
+
bash({ command: "npm install" })
|
|
1385
|
+
bash({ command: "git status" })
|
|
1386
|
+
|
|
1387
|
+
**Run in background (for dev servers, watchers, or interactive commands):**
|
|
1388
|
+
bash({ command: "npm run dev", background: true })
|
|
1389
|
+
\u2192 Returns { id: "abc123" } - save this ID
|
|
1390
|
+
|
|
1391
|
+
**Check on a background process:**
|
|
1392
|
+
bash({ id: "abc123" })
|
|
1393
|
+
bash({ id: "abc123", tail: 50 }) // last 50 lines only
|
|
1394
|
+
|
|
1395
|
+
**Stop a background process:**
|
|
1396
|
+
bash({ id: "abc123", kill: true })
|
|
1397
|
+
|
|
1398
|
+
**Respond to interactive prompts (for yes/no questions, etc.):**
|
|
1399
|
+
bash({ id: "abc123", key: "y" }) // send 'y' for yes
|
|
1400
|
+
bash({ id: "abc123", key: "n" }) // send 'n' for no
|
|
1401
|
+
bash({ id: "abc123", key: "Enter" }) // press Enter
|
|
1402
|
+
bash({ id: "abc123", input: "my text" }) // send text input
|
|
1403
|
+
|
|
1404
|
+
**IMPORTANT for interactive commands:**
|
|
1405
|
+
- Use --yes, -y, or similar flags to avoid prompts when available
|
|
1406
|
+
- For create-next-app: add --yes to accept defaults
|
|
1407
|
+
- For npm: add --yes or -y to skip confirmation
|
|
1408
|
+
- If prompts are unavoidable, run in background mode and use input/key to respond
|
|
1409
|
+
|
|
1410
|
+
Logs are saved to .sparkecoder/terminals/{id}/output.log`,
|
|
1411
|
+
inputSchema: bashInputSchema,
|
|
1412
|
+
execute: async (inputArgs) => {
|
|
1413
|
+
const { command, background, id, kill, tail, input: textInput, key } = inputArgs;
|
|
1414
|
+
if (id) {
|
|
1415
|
+
if (kill) {
|
|
1416
|
+
const success = await killTerminal(id);
|
|
1417
|
+
return {
|
|
1418
|
+
success,
|
|
1419
|
+
id,
|
|
1420
|
+
status: success ? "stopped" : "not_found",
|
|
1421
|
+
message: success ? `Terminal ${id} stopped` : `Terminal ${id} not found or already stopped`
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
if (textInput !== void 0) {
|
|
1425
|
+
const success = await sendInput(id, textInput, { pressEnter: true });
|
|
1426
|
+
if (!success) {
|
|
1427
|
+
return {
|
|
1428
|
+
success: false,
|
|
1429
|
+
id,
|
|
1430
|
+
error: `Terminal ${id} not found or not running`
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1434
|
+
const { output: output2, status: status2 } = await getLogs(id, options.workingDirectory, { tail: tail || 50, sessionId: options.sessionId });
|
|
1435
|
+
const truncatedOutput2 = truncateOutput(output2, MAX_OUTPUT_CHARS2);
|
|
1436
|
+
return {
|
|
1437
|
+
success: true,
|
|
1438
|
+
id,
|
|
1439
|
+
output: truncatedOutput2,
|
|
1440
|
+
status: status2,
|
|
1441
|
+
message: `Sent input "${textInput}" to terminal`
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
if (key) {
|
|
1445
|
+
const success = await sendKey(id, key);
|
|
1446
|
+
if (!success) {
|
|
1447
|
+
return {
|
|
1448
|
+
success: false,
|
|
1449
|
+
id,
|
|
1450
|
+
error: `Terminal ${id} not found or not running`
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1454
|
+
const { output: output2, status: status2 } = await getLogs(id, options.workingDirectory, { tail: tail || 50, sessionId: options.sessionId });
|
|
1455
|
+
const truncatedOutput2 = truncateOutput(output2, MAX_OUTPUT_CHARS2);
|
|
1456
|
+
return {
|
|
1457
|
+
success: true,
|
|
1458
|
+
id,
|
|
1459
|
+
output: truncatedOutput2,
|
|
1460
|
+
status: status2,
|
|
1461
|
+
message: `Sent key "${key}" to terminal`
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
const { output, status } = await getLogs(id, options.workingDirectory, { tail, sessionId: options.sessionId });
|
|
1465
|
+
const truncatedOutput = truncateOutput(output, MAX_OUTPUT_CHARS2);
|
|
1466
|
+
return {
|
|
1467
|
+
success: true,
|
|
1468
|
+
id,
|
|
1469
|
+
output: truncatedOutput,
|
|
1470
|
+
status
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
if (!command) {
|
|
1474
|
+
return {
|
|
1475
|
+
success: false,
|
|
1476
|
+
error: 'Either "command" (to run a new command) or "id" (to check/kill/send input) is required'
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
if (isBlockedCommand(command)) {
|
|
1480
|
+
return {
|
|
1481
|
+
success: false,
|
|
1482
|
+
error: "This command is blocked for safety reasons.",
|
|
1483
|
+
output: "",
|
|
1484
|
+
exitCode: 1
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
const canUseTmux = await shouldUseTmux();
|
|
1488
|
+
if (background) {
|
|
1489
|
+
if (!canUseTmux) {
|
|
1490
|
+
return {
|
|
1491
|
+
success: false,
|
|
1492
|
+
error: "Background mode requires tmux to be installed. Install with: brew install tmux (macOS) or apt install tmux (Linux)"
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
const terminalId = generateTerminalId();
|
|
1496
|
+
options.onProgress?.({ terminalId, status: "started", command });
|
|
1497
|
+
const result = await runBackground(command, options.workingDirectory, {
|
|
1498
|
+
sessionId: options.sessionId,
|
|
1499
|
+
terminalId
|
|
1500
|
+
});
|
|
1501
|
+
return {
|
|
1502
|
+
success: true,
|
|
1503
|
+
id: result.id,
|
|
1504
|
+
status: "running",
|
|
1505
|
+
message: `Started background process. Use bash({ id: "${result.id}" }) to check logs.`
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
if (canUseTmux) {
|
|
1509
|
+
const terminalId = generateTerminalId();
|
|
1510
|
+
options.onProgress?.({ terminalId, status: "started", command });
|
|
1511
|
+
try {
|
|
1512
|
+
const result = await runSync(command, options.workingDirectory, {
|
|
1513
|
+
sessionId: options.sessionId,
|
|
1514
|
+
timeout: COMMAND_TIMEOUT,
|
|
1515
|
+
terminalId
|
|
1516
|
+
});
|
|
1517
|
+
const truncatedOutput = truncateOutput(result.output, MAX_OUTPUT_CHARS2);
|
|
1518
|
+
options.onOutput?.(truncatedOutput);
|
|
1519
|
+
options.onProgress?.({ terminalId, status: "completed", command });
|
|
1520
|
+
return {
|
|
1521
|
+
success: result.exitCode === 0,
|
|
1522
|
+
id: result.id,
|
|
1523
|
+
output: truncatedOutput,
|
|
1524
|
+
exitCode: result.exitCode,
|
|
1525
|
+
status: result.status
|
|
1526
|
+
};
|
|
1527
|
+
} catch (error) {
|
|
1528
|
+
options.onProgress?.({ terminalId, status: "completed", command });
|
|
1529
|
+
return {
|
|
1530
|
+
success: false,
|
|
1531
|
+
error: error.message,
|
|
1532
|
+
output: "",
|
|
1533
|
+
exitCode: 1
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
} else {
|
|
1537
|
+
const result = await execFallback(command, options.workingDirectory, options.onOutput);
|
|
1538
|
+
return {
|
|
1539
|
+
success: result.success,
|
|
1540
|
+
output: result.output,
|
|
1541
|
+
exitCode: result.exitCode,
|
|
1542
|
+
error: result.error
|
|
1543
|
+
};
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// src/tools/read-file.ts
|
|
1550
|
+
import { tool as tool2 } from "ai";
|
|
1551
|
+
import { z as z3 } from "zod";
|
|
1552
|
+
import { readFile as readFile2, stat } from "fs/promises";
|
|
1553
|
+
import { resolve as resolve2, relative, isAbsolute } from "path";
|
|
1554
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1555
|
+
var MAX_FILE_SIZE = 5 * 1024 * 1024;
|
|
1556
|
+
var MAX_OUTPUT_CHARS3 = 5e4;
|
|
1557
|
+
var readFileInputSchema = z3.object({
|
|
1558
|
+
path: z3.string().describe("The path to the file to read. Can be relative to working directory or absolute."),
|
|
1559
|
+
startLine: z3.number().optional().describe("Optional: Start reading from this line number (1-indexed)"),
|
|
1560
|
+
endLine: z3.number().optional().describe("Optional: Stop reading at this line number (1-indexed, inclusive)")
|
|
1561
|
+
});
|
|
1562
|
+
function createReadFileTool(options) {
|
|
1563
|
+
return tool2({
|
|
1564
|
+
description: `Read the contents of a file. Provide a path relative to the working directory (${options.workingDirectory}) or an absolute path.
|
|
1565
|
+
Large files will be automatically truncated. Binary files are not supported.
|
|
1566
|
+
Use this to understand existing code, check file contents, or gather context.`,
|
|
780
1567
|
inputSchema: readFileInputSchema,
|
|
781
1568
|
execute: async ({ path, startLine, endLine }) => {
|
|
782
1569
|
try {
|
|
@@ -789,7 +1576,7 @@ Use this to understand existing code, check file contents, or gather context.`,
|
|
|
789
1576
|
content: null
|
|
790
1577
|
};
|
|
791
1578
|
}
|
|
792
|
-
if (!
|
|
1579
|
+
if (!existsSync3(absolutePath)) {
|
|
793
1580
|
return {
|
|
794
1581
|
success: false,
|
|
795
1582
|
error: `File not found: ${path}`,
|
|
@@ -811,7 +1598,7 @@ Use this to understand existing code, check file contents, or gather context.`,
|
|
|
811
1598
|
content: null
|
|
812
1599
|
};
|
|
813
1600
|
}
|
|
814
|
-
let content = await
|
|
1601
|
+
let content = await readFile2(absolutePath, "utf-8");
|
|
815
1602
|
if (startLine !== void 0 || endLine !== void 0) {
|
|
816
1603
|
const lines = content.split("\n");
|
|
817
1604
|
const start = (startLine ?? 1) - 1;
|
|
@@ -859,9 +1646,198 @@ Use this to understand existing code, check file contents, or gather context.`,
|
|
|
859
1646
|
// src/tools/write-file.ts
|
|
860
1647
|
import { tool as tool3 } from "ai";
|
|
861
1648
|
import { z as z4 } from "zod";
|
|
862
|
-
import { readFile as
|
|
863
|
-
import { resolve as
|
|
864
|
-
import { existsSync as
|
|
1649
|
+
import { readFile as readFile4, writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
|
|
1650
|
+
import { resolve as resolve4, relative as relative3, isAbsolute as isAbsolute2, dirname as dirname3 } from "path";
|
|
1651
|
+
import { existsSync as existsSync5 } from "fs";
|
|
1652
|
+
|
|
1653
|
+
// src/checkpoints/index.ts
|
|
1654
|
+
import { readFile as readFile3, writeFile as writeFile2, unlink, mkdir as mkdir2 } from "fs/promises";
|
|
1655
|
+
import { existsSync as existsSync4 } from "fs";
|
|
1656
|
+
import { resolve as resolve3, relative as relative2, dirname as dirname2 } from "path";
|
|
1657
|
+
import { exec as exec3 } from "child_process";
|
|
1658
|
+
import { promisify as promisify3 } from "util";
|
|
1659
|
+
var execAsync3 = promisify3(exec3);
|
|
1660
|
+
async function getGitHead(workingDirectory) {
|
|
1661
|
+
try {
|
|
1662
|
+
const { stdout } = await execAsync3("git rev-parse HEAD", {
|
|
1663
|
+
cwd: workingDirectory,
|
|
1664
|
+
timeout: 5e3
|
|
1665
|
+
});
|
|
1666
|
+
return stdout.trim();
|
|
1667
|
+
} catch {
|
|
1668
|
+
return void 0;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
var activeManagers = /* @__PURE__ */ new Map();
|
|
1672
|
+
function getCheckpointManager(sessionId, workingDirectory) {
|
|
1673
|
+
let manager = activeManagers.get(sessionId);
|
|
1674
|
+
if (!manager) {
|
|
1675
|
+
manager = {
|
|
1676
|
+
sessionId,
|
|
1677
|
+
workingDirectory,
|
|
1678
|
+
currentCheckpointId: null
|
|
1679
|
+
};
|
|
1680
|
+
activeManagers.set(sessionId, manager);
|
|
1681
|
+
}
|
|
1682
|
+
return manager;
|
|
1683
|
+
}
|
|
1684
|
+
async function createCheckpoint(sessionId, workingDirectory, messageSequence) {
|
|
1685
|
+
const gitHead = await getGitHead(workingDirectory);
|
|
1686
|
+
const checkpoint = checkpointQueries.create({
|
|
1687
|
+
sessionId,
|
|
1688
|
+
messageSequence,
|
|
1689
|
+
gitHead
|
|
1690
|
+
});
|
|
1691
|
+
const manager = getCheckpointManager(sessionId, workingDirectory);
|
|
1692
|
+
manager.currentCheckpointId = checkpoint.id;
|
|
1693
|
+
return checkpoint;
|
|
1694
|
+
}
|
|
1695
|
+
async function backupFile(sessionId, workingDirectory, filePath) {
|
|
1696
|
+
const manager = getCheckpointManager(sessionId, workingDirectory);
|
|
1697
|
+
if (!manager.currentCheckpointId) {
|
|
1698
|
+
console.warn("[checkpoint] No active checkpoint, skipping file backup");
|
|
1699
|
+
return null;
|
|
1700
|
+
}
|
|
1701
|
+
const absolutePath = resolve3(workingDirectory, filePath);
|
|
1702
|
+
const relativePath = relative2(workingDirectory, absolutePath);
|
|
1703
|
+
if (fileBackupQueries.hasBackup(manager.currentCheckpointId, relativePath)) {
|
|
1704
|
+
return null;
|
|
1705
|
+
}
|
|
1706
|
+
let originalContent = null;
|
|
1707
|
+
let existed = false;
|
|
1708
|
+
if (existsSync4(absolutePath)) {
|
|
1709
|
+
try {
|
|
1710
|
+
originalContent = await readFile3(absolutePath, "utf-8");
|
|
1711
|
+
existed = true;
|
|
1712
|
+
} catch (error) {
|
|
1713
|
+
console.warn(`[checkpoint] Failed to read file for backup: ${error.message}`);
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
const backup = fileBackupQueries.create({
|
|
1717
|
+
checkpointId: manager.currentCheckpointId,
|
|
1718
|
+
sessionId,
|
|
1719
|
+
filePath: relativePath,
|
|
1720
|
+
originalContent,
|
|
1721
|
+
existed
|
|
1722
|
+
});
|
|
1723
|
+
return backup;
|
|
1724
|
+
}
|
|
1725
|
+
async function revertToCheckpoint(sessionId, checkpointId) {
|
|
1726
|
+
const session = sessionQueries.getById(sessionId);
|
|
1727
|
+
if (!session) {
|
|
1728
|
+
return {
|
|
1729
|
+
success: false,
|
|
1730
|
+
filesRestored: 0,
|
|
1731
|
+
filesDeleted: 0,
|
|
1732
|
+
messagesDeleted: 0,
|
|
1733
|
+
checkpointsDeleted: 0,
|
|
1734
|
+
error: "Session not found"
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
const checkpoint = checkpointQueries.getById(checkpointId);
|
|
1738
|
+
if (!checkpoint || checkpoint.sessionId !== sessionId) {
|
|
1739
|
+
return {
|
|
1740
|
+
success: false,
|
|
1741
|
+
filesRestored: 0,
|
|
1742
|
+
filesDeleted: 0,
|
|
1743
|
+
messagesDeleted: 0,
|
|
1744
|
+
checkpointsDeleted: 0,
|
|
1745
|
+
error: "Checkpoint not found"
|
|
1746
|
+
};
|
|
1747
|
+
}
|
|
1748
|
+
const workingDirectory = session.workingDirectory;
|
|
1749
|
+
const backupsToRevert = fileBackupQueries.getFromSequence(sessionId, checkpoint.messageSequence);
|
|
1750
|
+
const fileToEarliestBackup = /* @__PURE__ */ new Map();
|
|
1751
|
+
for (const backup of backupsToRevert) {
|
|
1752
|
+
if (!fileToEarliestBackup.has(backup.filePath)) {
|
|
1753
|
+
fileToEarliestBackup.set(backup.filePath, backup);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
let filesRestored = 0;
|
|
1757
|
+
let filesDeleted = 0;
|
|
1758
|
+
for (const [filePath, backup] of fileToEarliestBackup) {
|
|
1759
|
+
const absolutePath = resolve3(workingDirectory, filePath);
|
|
1760
|
+
try {
|
|
1761
|
+
if (backup.existed && backup.originalContent !== null) {
|
|
1762
|
+
const dir = dirname2(absolutePath);
|
|
1763
|
+
if (!existsSync4(dir)) {
|
|
1764
|
+
await mkdir2(dir, { recursive: true });
|
|
1765
|
+
}
|
|
1766
|
+
await writeFile2(absolutePath, backup.originalContent, "utf-8");
|
|
1767
|
+
filesRestored++;
|
|
1768
|
+
} else if (!backup.existed) {
|
|
1769
|
+
if (existsSync4(absolutePath)) {
|
|
1770
|
+
await unlink(absolutePath);
|
|
1771
|
+
filesDeleted++;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
} catch (error) {
|
|
1775
|
+
console.error(`Failed to restore ${filePath}: ${error.message}`);
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
const messagesDeleted = messageQueries.deleteFromSequence(sessionId, checkpoint.messageSequence);
|
|
1779
|
+
toolExecutionQueries.deleteAfterTime(sessionId, checkpoint.createdAt);
|
|
1780
|
+
const checkpointsDeleted = checkpointQueries.deleteAfterSequence(sessionId, checkpoint.messageSequence);
|
|
1781
|
+
const manager = getCheckpointManager(sessionId, workingDirectory);
|
|
1782
|
+
manager.currentCheckpointId = checkpoint.id;
|
|
1783
|
+
return {
|
|
1784
|
+
success: true,
|
|
1785
|
+
filesRestored,
|
|
1786
|
+
filesDeleted,
|
|
1787
|
+
messagesDeleted,
|
|
1788
|
+
checkpointsDeleted
|
|
1789
|
+
};
|
|
1790
|
+
}
|
|
1791
|
+
function getCheckpoints(sessionId) {
|
|
1792
|
+
return checkpointQueries.getBySession(sessionId);
|
|
1793
|
+
}
|
|
1794
|
+
async function getSessionDiff(sessionId) {
|
|
1795
|
+
const session = sessionQueries.getById(sessionId);
|
|
1796
|
+
if (!session) {
|
|
1797
|
+
return { files: [] };
|
|
1798
|
+
}
|
|
1799
|
+
const workingDirectory = session.workingDirectory;
|
|
1800
|
+
const allBackups = fileBackupQueries.getBySession(sessionId);
|
|
1801
|
+
const fileToOriginalBackup = /* @__PURE__ */ new Map();
|
|
1802
|
+
for (const backup of allBackups) {
|
|
1803
|
+
if (!fileToOriginalBackup.has(backup.filePath)) {
|
|
1804
|
+
fileToOriginalBackup.set(backup.filePath, backup);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
const files = [];
|
|
1808
|
+
for (const [filePath, originalBackup] of fileToOriginalBackup) {
|
|
1809
|
+
const absolutePath = resolve3(workingDirectory, filePath);
|
|
1810
|
+
let currentContent = null;
|
|
1811
|
+
let currentExists = false;
|
|
1812
|
+
if (existsSync4(absolutePath)) {
|
|
1813
|
+
try {
|
|
1814
|
+
currentContent = await readFile3(absolutePath, "utf-8");
|
|
1815
|
+
currentExists = true;
|
|
1816
|
+
} catch {
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
let status;
|
|
1820
|
+
if (!originalBackup.existed && currentExists) {
|
|
1821
|
+
status = "created";
|
|
1822
|
+
} else if (originalBackup.existed && !currentExists) {
|
|
1823
|
+
status = "deleted";
|
|
1824
|
+
} else {
|
|
1825
|
+
status = "modified";
|
|
1826
|
+
}
|
|
1827
|
+
files.push({
|
|
1828
|
+
path: filePath,
|
|
1829
|
+
status,
|
|
1830
|
+
originalContent: originalBackup.originalContent,
|
|
1831
|
+
currentContent
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
return { files };
|
|
1835
|
+
}
|
|
1836
|
+
function clearCheckpointManager(sessionId) {
|
|
1837
|
+
activeManagers.delete(sessionId);
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// src/tools/write-file.ts
|
|
865
1841
|
var writeFileInputSchema = z4.object({
|
|
866
1842
|
path: z4.string().describe("The path to the file. Can be relative to working directory or absolute."),
|
|
867
1843
|
mode: z4.enum(["full", "str_replace"]).describe('Write mode: "full" for complete file write, "str_replace" for targeted string replacement'),
|
|
@@ -890,8 +1866,8 @@ Working directory: ${options.workingDirectory}`,
|
|
|
890
1866
|
inputSchema: writeFileInputSchema,
|
|
891
1867
|
execute: async ({ path, mode, content, old_string, new_string }) => {
|
|
892
1868
|
try {
|
|
893
|
-
const absolutePath = isAbsolute2(path) ? path :
|
|
894
|
-
const relativePath =
|
|
1869
|
+
const absolutePath = isAbsolute2(path) ? path : resolve4(options.workingDirectory, path);
|
|
1870
|
+
const relativePath = relative3(options.workingDirectory, absolutePath);
|
|
895
1871
|
if (relativePath.startsWith("..") && !isAbsolute2(path)) {
|
|
896
1872
|
return {
|
|
897
1873
|
success: false,
|
|
@@ -905,16 +1881,17 @@ Working directory: ${options.workingDirectory}`,
|
|
|
905
1881
|
error: 'Content is required for "full" mode'
|
|
906
1882
|
};
|
|
907
1883
|
}
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1884
|
+
await backupFile(options.sessionId, options.workingDirectory, absolutePath);
|
|
1885
|
+
const dir = dirname3(absolutePath);
|
|
1886
|
+
if (!existsSync5(dir)) {
|
|
1887
|
+
await mkdir3(dir, { recursive: true });
|
|
911
1888
|
}
|
|
912
|
-
const existed =
|
|
913
|
-
await
|
|
1889
|
+
const existed = existsSync5(absolutePath);
|
|
1890
|
+
await writeFile3(absolutePath, content, "utf-8");
|
|
914
1891
|
return {
|
|
915
1892
|
success: true,
|
|
916
1893
|
path: absolutePath,
|
|
917
|
-
relativePath:
|
|
1894
|
+
relativePath: relative3(options.workingDirectory, absolutePath),
|
|
918
1895
|
mode: "full",
|
|
919
1896
|
action: existed ? "replaced" : "created",
|
|
920
1897
|
bytesWritten: Buffer.byteLength(content, "utf-8"),
|
|
@@ -927,13 +1904,14 @@ Working directory: ${options.workingDirectory}`,
|
|
|
927
1904
|
error: 'Both old_string and new_string are required for "str_replace" mode'
|
|
928
1905
|
};
|
|
929
1906
|
}
|
|
930
|
-
if (!
|
|
1907
|
+
if (!existsSync5(absolutePath)) {
|
|
931
1908
|
return {
|
|
932
1909
|
success: false,
|
|
933
1910
|
error: `File not found: ${path}. Use "full" mode to create new files.`
|
|
934
1911
|
};
|
|
935
1912
|
}
|
|
936
|
-
|
|
1913
|
+
await backupFile(options.sessionId, options.workingDirectory, absolutePath);
|
|
1914
|
+
const currentContent = await readFile4(absolutePath, "utf-8");
|
|
937
1915
|
if (!currentContent.includes(old_string)) {
|
|
938
1916
|
const lines = currentContent.split("\n");
|
|
939
1917
|
const preview = lines.slice(0, 20).join("\n");
|
|
@@ -954,13 +1932,13 @@ Working directory: ${options.workingDirectory}`,
|
|
|
954
1932
|
};
|
|
955
1933
|
}
|
|
956
1934
|
const newContent = currentContent.replace(old_string, new_string);
|
|
957
|
-
await
|
|
1935
|
+
await writeFile3(absolutePath, newContent, "utf-8");
|
|
958
1936
|
const oldLines = old_string.split("\n").length;
|
|
959
1937
|
const newLines = new_string.split("\n").length;
|
|
960
1938
|
return {
|
|
961
1939
|
success: true,
|
|
962
1940
|
path: absolutePath,
|
|
963
|
-
relativePath:
|
|
1941
|
+
relativePath: relative3(options.workingDirectory, absolutePath),
|
|
964
1942
|
mode: "str_replace",
|
|
965
1943
|
linesRemoved: oldLines,
|
|
966
1944
|
linesAdded: newLines,
|
|
@@ -1111,9 +2089,9 @@ import { tool as tool5 } from "ai";
|
|
|
1111
2089
|
import { z as z6 } from "zod";
|
|
1112
2090
|
|
|
1113
2091
|
// src/skills/index.ts
|
|
1114
|
-
import { readFile as
|
|
1115
|
-
import { resolve as
|
|
1116
|
-
import { existsSync as
|
|
2092
|
+
import { readFile as readFile5, readdir } from "fs/promises";
|
|
2093
|
+
import { resolve as resolve5, basename, extname } from "path";
|
|
2094
|
+
import { existsSync as existsSync6 } from "fs";
|
|
1117
2095
|
function parseSkillFrontmatter(content) {
|
|
1118
2096
|
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
1119
2097
|
if (!frontmatterMatch) {
|
|
@@ -1144,15 +2122,15 @@ function getSkillNameFromPath(filePath) {
|
|
|
1144
2122
|
return basename(filePath, extname(filePath)).replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
1145
2123
|
}
|
|
1146
2124
|
async function loadSkillsFromDirectory(directory) {
|
|
1147
|
-
if (!
|
|
2125
|
+
if (!existsSync6(directory)) {
|
|
1148
2126
|
return [];
|
|
1149
2127
|
}
|
|
1150
2128
|
const skills = [];
|
|
1151
2129
|
const files = await readdir(directory);
|
|
1152
2130
|
for (const file of files) {
|
|
1153
2131
|
if (!file.endsWith(".md")) continue;
|
|
1154
|
-
const filePath =
|
|
1155
|
-
const content = await
|
|
2132
|
+
const filePath = resolve5(directory, file);
|
|
2133
|
+
const content = await readFile5(filePath, "utf-8");
|
|
1156
2134
|
const parsed = parseSkillFrontmatter(content);
|
|
1157
2135
|
if (parsed) {
|
|
1158
2136
|
skills.push({
|
|
@@ -1194,7 +2172,7 @@ async function loadSkillContent(skillName, directories) {
|
|
|
1194
2172
|
if (!skill) {
|
|
1195
2173
|
return null;
|
|
1196
2174
|
}
|
|
1197
|
-
const content = await
|
|
2175
|
+
const content = await readFile5(skill.filePath, "utf-8");
|
|
1198
2176
|
const parsed = parseSkillFrontmatter(content);
|
|
1199
2177
|
return {
|
|
1200
2178
|
...skill,
|
|
@@ -1292,460 +2270,21 @@ Once loaded, a skill's content will be available in the conversation context.`,
|
|
|
1292
2270
|
});
|
|
1293
2271
|
}
|
|
1294
2272
|
|
|
1295
|
-
// src/tools/terminal.ts
|
|
1296
|
-
import { tool as tool6 } from "ai";
|
|
1297
|
-
import { z as z7 } from "zod";
|
|
1298
|
-
|
|
1299
|
-
// src/terminal/manager.ts
|
|
1300
|
-
import { spawn } from "child_process";
|
|
1301
|
-
import { EventEmitter } from "events";
|
|
1302
|
-
var LogBuffer = class {
|
|
1303
|
-
buffer = [];
|
|
1304
|
-
maxSize;
|
|
1305
|
-
totalBytes = 0;
|
|
1306
|
-
maxBytes;
|
|
1307
|
-
constructor(maxBytes = 50 * 1024) {
|
|
1308
|
-
this.maxBytes = maxBytes;
|
|
1309
|
-
this.maxSize = 1e3;
|
|
1310
|
-
}
|
|
1311
|
-
append(data) {
|
|
1312
|
-
const lines = data.split("\n");
|
|
1313
|
-
for (const line of lines) {
|
|
1314
|
-
if (line) {
|
|
1315
|
-
this.buffer.push(line);
|
|
1316
|
-
this.totalBytes += line.length;
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
while (this.totalBytes > this.maxBytes && this.buffer.length > 1) {
|
|
1320
|
-
const removed = this.buffer.shift();
|
|
1321
|
-
if (removed) {
|
|
1322
|
-
this.totalBytes -= removed.length;
|
|
1323
|
-
}
|
|
1324
|
-
}
|
|
1325
|
-
while (this.buffer.length > this.maxSize) {
|
|
1326
|
-
const removed = this.buffer.shift();
|
|
1327
|
-
if (removed) {
|
|
1328
|
-
this.totalBytes -= removed.length;
|
|
1329
|
-
}
|
|
1330
|
-
}
|
|
1331
|
-
}
|
|
1332
|
-
getAll() {
|
|
1333
|
-
return this.buffer.join("\n");
|
|
1334
|
-
}
|
|
1335
|
-
getTail(lines) {
|
|
1336
|
-
const start = Math.max(0, this.buffer.length - lines);
|
|
1337
|
-
return this.buffer.slice(start).join("\n");
|
|
1338
|
-
}
|
|
1339
|
-
clear() {
|
|
1340
|
-
this.buffer = [];
|
|
1341
|
-
this.totalBytes = 0;
|
|
1342
|
-
}
|
|
1343
|
-
get lineCount() {
|
|
1344
|
-
return this.buffer.length;
|
|
1345
|
-
}
|
|
1346
|
-
};
|
|
1347
|
-
var TerminalManager = class _TerminalManager extends EventEmitter {
|
|
1348
|
-
processes = /* @__PURE__ */ new Map();
|
|
1349
|
-
static instance = null;
|
|
1350
|
-
constructor() {
|
|
1351
|
-
super();
|
|
1352
|
-
}
|
|
1353
|
-
static getInstance() {
|
|
1354
|
-
if (!_TerminalManager.instance) {
|
|
1355
|
-
_TerminalManager.instance = new _TerminalManager();
|
|
1356
|
-
}
|
|
1357
|
-
return _TerminalManager.instance;
|
|
1358
|
-
}
|
|
1359
|
-
/**
|
|
1360
|
-
* Spawn a new background process
|
|
1361
|
-
*/
|
|
1362
|
-
spawn(options) {
|
|
1363
|
-
const { sessionId, command, cwd, name, env } = options;
|
|
1364
|
-
const parts = this.parseCommand(command);
|
|
1365
|
-
const executable = parts[0];
|
|
1366
|
-
const args = parts.slice(1);
|
|
1367
|
-
const terminal = terminalQueries.create({
|
|
1368
|
-
sessionId,
|
|
1369
|
-
name: name || null,
|
|
1370
|
-
command,
|
|
1371
|
-
cwd: cwd || process.cwd(),
|
|
1372
|
-
status: "running"
|
|
1373
|
-
});
|
|
1374
|
-
const proc = spawn(executable, args, {
|
|
1375
|
-
cwd: cwd || process.cwd(),
|
|
1376
|
-
shell: true,
|
|
1377
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
1378
|
-
env: { ...process.env, ...env },
|
|
1379
|
-
detached: false
|
|
1380
|
-
});
|
|
1381
|
-
if (proc.pid) {
|
|
1382
|
-
terminalQueries.updatePid(terminal.id, proc.pid);
|
|
1383
|
-
}
|
|
1384
|
-
const logs = new LogBuffer();
|
|
1385
|
-
proc.stdout?.on("data", (data) => {
|
|
1386
|
-
const text2 = data.toString();
|
|
1387
|
-
logs.append(text2);
|
|
1388
|
-
this.emit("stdout", { terminalId: terminal.id, data: text2 });
|
|
1389
|
-
});
|
|
1390
|
-
proc.stderr?.on("data", (data) => {
|
|
1391
|
-
const text2 = data.toString();
|
|
1392
|
-
logs.append(`[stderr] ${text2}`);
|
|
1393
|
-
this.emit("stderr", { terminalId: terminal.id, data: text2 });
|
|
1394
|
-
});
|
|
1395
|
-
proc.on("exit", (code, signal) => {
|
|
1396
|
-
const exitCode = code ?? (signal ? 128 : 0);
|
|
1397
|
-
terminalQueries.updateStatus(terminal.id, "stopped", exitCode);
|
|
1398
|
-
this.emit("exit", { terminalId: terminal.id, code: exitCode, signal });
|
|
1399
|
-
const managed2 = this.processes.get(terminal.id);
|
|
1400
|
-
if (managed2) {
|
|
1401
|
-
managed2.terminal = { ...managed2.terminal, status: "stopped", exitCode };
|
|
1402
|
-
}
|
|
1403
|
-
});
|
|
1404
|
-
proc.on("error", (err) => {
|
|
1405
|
-
terminalQueries.updateStatus(terminal.id, "error", void 0, err.message);
|
|
1406
|
-
this.emit("error", { terminalId: terminal.id, error: err.message });
|
|
1407
|
-
const managed2 = this.processes.get(terminal.id);
|
|
1408
|
-
if (managed2) {
|
|
1409
|
-
managed2.terminal = { ...managed2.terminal, status: "error", error: err.message };
|
|
1410
|
-
}
|
|
1411
|
-
});
|
|
1412
|
-
const managed = {
|
|
1413
|
-
id: terminal.id,
|
|
1414
|
-
process: proc,
|
|
1415
|
-
logs,
|
|
1416
|
-
terminal: { ...terminal, pid: proc.pid ?? null }
|
|
1417
|
-
};
|
|
1418
|
-
this.processes.set(terminal.id, managed);
|
|
1419
|
-
return this.toTerminalInfo(managed.terminal);
|
|
1420
|
-
}
|
|
1421
|
-
/**
|
|
1422
|
-
* Get logs from a terminal
|
|
1423
|
-
*/
|
|
1424
|
-
getLogs(terminalId, tail) {
|
|
1425
|
-
const managed = this.processes.get(terminalId);
|
|
1426
|
-
if (!managed) {
|
|
1427
|
-
return null;
|
|
1428
|
-
}
|
|
1429
|
-
return {
|
|
1430
|
-
logs: tail ? managed.logs.getTail(tail) : managed.logs.getAll(),
|
|
1431
|
-
lineCount: managed.logs.lineCount
|
|
1432
|
-
};
|
|
1433
|
-
}
|
|
1434
|
-
/**
|
|
1435
|
-
* Get terminal status
|
|
1436
|
-
*/
|
|
1437
|
-
getStatus(terminalId) {
|
|
1438
|
-
const managed = this.processes.get(terminalId);
|
|
1439
|
-
if (managed) {
|
|
1440
|
-
if (managed.process.exitCode !== null) {
|
|
1441
|
-
managed.terminal = {
|
|
1442
|
-
...managed.terminal,
|
|
1443
|
-
status: "stopped",
|
|
1444
|
-
exitCode: managed.process.exitCode
|
|
1445
|
-
};
|
|
1446
|
-
}
|
|
1447
|
-
return this.toTerminalInfo(managed.terminal);
|
|
1448
|
-
}
|
|
1449
|
-
const terminal = terminalQueries.getById(terminalId);
|
|
1450
|
-
if (terminal) {
|
|
1451
|
-
return this.toTerminalInfo(terminal);
|
|
1452
|
-
}
|
|
1453
|
-
return null;
|
|
1454
|
-
}
|
|
1455
|
-
/**
|
|
1456
|
-
* Kill a terminal process
|
|
1457
|
-
*/
|
|
1458
|
-
kill(terminalId, signal = "SIGTERM") {
|
|
1459
|
-
const managed = this.processes.get(terminalId);
|
|
1460
|
-
if (!managed) {
|
|
1461
|
-
return false;
|
|
1462
|
-
}
|
|
1463
|
-
try {
|
|
1464
|
-
managed.process.kill(signal);
|
|
1465
|
-
return true;
|
|
1466
|
-
} catch (err) {
|
|
1467
|
-
console.error(`Failed to kill terminal ${terminalId}:`, err);
|
|
1468
|
-
return false;
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
/**
|
|
1472
|
-
* Write to a terminal's stdin
|
|
1473
|
-
*/
|
|
1474
|
-
write(terminalId, input) {
|
|
1475
|
-
const managed = this.processes.get(terminalId);
|
|
1476
|
-
if (!managed || !managed.process.stdin) {
|
|
1477
|
-
return false;
|
|
1478
|
-
}
|
|
1479
|
-
try {
|
|
1480
|
-
managed.process.stdin.write(input);
|
|
1481
|
-
return true;
|
|
1482
|
-
} catch (err) {
|
|
1483
|
-
console.error(`Failed to write to terminal ${terminalId}:`, err);
|
|
1484
|
-
return false;
|
|
1485
|
-
}
|
|
1486
|
-
}
|
|
1487
|
-
/**
|
|
1488
|
-
* List all terminals for a session
|
|
1489
|
-
*/
|
|
1490
|
-
list(sessionId) {
|
|
1491
|
-
const terminals3 = terminalQueries.getBySession(sessionId);
|
|
1492
|
-
return terminals3.map((t) => {
|
|
1493
|
-
const managed = this.processes.get(t.id);
|
|
1494
|
-
if (managed) {
|
|
1495
|
-
return this.toTerminalInfo(managed.terminal);
|
|
1496
|
-
}
|
|
1497
|
-
return this.toTerminalInfo(t);
|
|
1498
|
-
});
|
|
1499
|
-
}
|
|
1500
|
-
/**
|
|
1501
|
-
* Get all running terminals for a session
|
|
1502
|
-
*/
|
|
1503
|
-
getRunning(sessionId) {
|
|
1504
|
-
return this.list(sessionId).filter((t) => t.status === "running");
|
|
1505
|
-
}
|
|
1506
|
-
/**
|
|
1507
|
-
* Kill all terminals for a session (cleanup)
|
|
1508
|
-
*/
|
|
1509
|
-
killAll(sessionId) {
|
|
1510
|
-
let killed = 0;
|
|
1511
|
-
for (const [id, managed] of this.processes) {
|
|
1512
|
-
if (managed.terminal.sessionId === sessionId) {
|
|
1513
|
-
if (this.kill(id)) {
|
|
1514
|
-
killed++;
|
|
1515
|
-
}
|
|
1516
|
-
}
|
|
1517
|
-
}
|
|
1518
|
-
return killed;
|
|
1519
|
-
}
|
|
1520
|
-
/**
|
|
1521
|
-
* Clean up stopped terminals from memory (keep DB records)
|
|
1522
|
-
*/
|
|
1523
|
-
cleanup(sessionId) {
|
|
1524
|
-
let cleaned = 0;
|
|
1525
|
-
for (const [id, managed] of this.processes) {
|
|
1526
|
-
if (sessionId && managed.terminal.sessionId !== sessionId) {
|
|
1527
|
-
continue;
|
|
1528
|
-
}
|
|
1529
|
-
if (managed.terminal.status !== "running") {
|
|
1530
|
-
this.processes.delete(id);
|
|
1531
|
-
cleaned++;
|
|
1532
|
-
}
|
|
1533
|
-
}
|
|
1534
|
-
return cleaned;
|
|
1535
|
-
}
|
|
1536
|
-
/**
|
|
1537
|
-
* Parse a command string into executable and arguments
|
|
1538
|
-
*/
|
|
1539
|
-
parseCommand(command) {
|
|
1540
|
-
const parts = [];
|
|
1541
|
-
let current = "";
|
|
1542
|
-
let inQuote = false;
|
|
1543
|
-
let quoteChar = "";
|
|
1544
|
-
for (const char of command) {
|
|
1545
|
-
if ((char === '"' || char === "'") && !inQuote) {
|
|
1546
|
-
inQuote = true;
|
|
1547
|
-
quoteChar = char;
|
|
1548
|
-
} else if (char === quoteChar && inQuote) {
|
|
1549
|
-
inQuote = false;
|
|
1550
|
-
quoteChar = "";
|
|
1551
|
-
} else if (char === " " && !inQuote) {
|
|
1552
|
-
if (current) {
|
|
1553
|
-
parts.push(current);
|
|
1554
|
-
current = "";
|
|
1555
|
-
}
|
|
1556
|
-
} else {
|
|
1557
|
-
current += char;
|
|
1558
|
-
}
|
|
1559
|
-
}
|
|
1560
|
-
if (current) {
|
|
1561
|
-
parts.push(current);
|
|
1562
|
-
}
|
|
1563
|
-
return parts.length > 0 ? parts : [command];
|
|
1564
|
-
}
|
|
1565
|
-
toTerminalInfo(terminal) {
|
|
1566
|
-
return {
|
|
1567
|
-
id: terminal.id,
|
|
1568
|
-
name: terminal.name,
|
|
1569
|
-
command: terminal.command,
|
|
1570
|
-
cwd: terminal.cwd,
|
|
1571
|
-
pid: terminal.pid,
|
|
1572
|
-
status: terminal.status,
|
|
1573
|
-
exitCode: terminal.exitCode,
|
|
1574
|
-
error: terminal.error,
|
|
1575
|
-
createdAt: terminal.createdAt,
|
|
1576
|
-
stoppedAt: terminal.stoppedAt
|
|
1577
|
-
};
|
|
1578
|
-
}
|
|
1579
|
-
};
|
|
1580
|
-
function getTerminalManager() {
|
|
1581
|
-
return TerminalManager.getInstance();
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
// src/tools/terminal.ts
|
|
1585
|
-
var TerminalInputSchema = z7.object({
|
|
1586
|
-
action: z7.enum(["spawn", "logs", "status", "kill", "write", "list"]).describe(
|
|
1587
|
-
"The action to perform: spawn (start process), logs (get output), status (check if running), kill (stop process), write (send stdin), list (show all)"
|
|
1588
|
-
),
|
|
1589
|
-
// For spawn
|
|
1590
|
-
command: z7.string().optional().describe('For spawn: The command to run (e.g., "npm run dev")'),
|
|
1591
|
-
cwd: z7.string().optional().describe("For spawn: Working directory for the command"),
|
|
1592
|
-
name: z7.string().optional().describe('For spawn: Optional friendly name (e.g., "dev-server")'),
|
|
1593
|
-
// For logs, status, kill, write
|
|
1594
|
-
terminalId: z7.string().optional().describe("For logs/status/kill/write: The terminal ID"),
|
|
1595
|
-
tail: z7.number().optional().describe("For logs: Number of lines to return from the end"),
|
|
1596
|
-
// For kill
|
|
1597
|
-
signal: z7.enum(["SIGTERM", "SIGKILL"]).optional().describe("For kill: Signal to send (default: SIGTERM)"),
|
|
1598
|
-
// For write
|
|
1599
|
-
input: z7.string().optional().describe("For write: The input to send to stdin")
|
|
1600
|
-
});
|
|
1601
|
-
function createTerminalTool(options) {
|
|
1602
|
-
const { sessionId, workingDirectory } = options;
|
|
1603
|
-
return tool6({
|
|
1604
|
-
description: `Manage background terminal processes. Use this for long-running commands like dev servers, watchers, or any process that shouldn't block.
|
|
1605
|
-
|
|
1606
|
-
Actions:
|
|
1607
|
-
- spawn: Start a new background process. Requires 'command'. Returns terminal ID.
|
|
1608
|
-
- logs: Get output from a terminal. Requires 'terminalId'. Optional 'tail' for recent lines.
|
|
1609
|
-
- status: Check if a terminal is still running. Requires 'terminalId'.
|
|
1610
|
-
- kill: Stop a terminal process. Requires 'terminalId'. Optional 'signal'.
|
|
1611
|
-
- write: Send input to a terminal's stdin. Requires 'terminalId' and 'input'.
|
|
1612
|
-
- list: Show all terminals for this session. No other params needed.
|
|
1613
|
-
|
|
1614
|
-
Example workflow:
|
|
1615
|
-
1. spawn with command="npm run dev", name="dev-server" \u2192 { id: "abc123", status: "running" }
|
|
1616
|
-
2. logs with terminalId="abc123", tail=10 \u2192 "Ready on http://localhost:3000"
|
|
1617
|
-
3. kill with terminalId="abc123" \u2192 { success: true }`,
|
|
1618
|
-
inputSchema: TerminalInputSchema,
|
|
1619
|
-
execute: async (input) => {
|
|
1620
|
-
const manager = getTerminalManager();
|
|
1621
|
-
switch (input.action) {
|
|
1622
|
-
case "spawn": {
|
|
1623
|
-
if (!input.command) {
|
|
1624
|
-
return { success: false, error: 'spawn requires a "command" parameter' };
|
|
1625
|
-
}
|
|
1626
|
-
const terminal = manager.spawn({
|
|
1627
|
-
sessionId,
|
|
1628
|
-
command: input.command,
|
|
1629
|
-
cwd: input.cwd || workingDirectory,
|
|
1630
|
-
name: input.name
|
|
1631
|
-
});
|
|
1632
|
-
return {
|
|
1633
|
-
success: true,
|
|
1634
|
-
terminal: formatTerminal(terminal),
|
|
1635
|
-
message: `Started "${input.command}" with terminal ID: ${terminal.id}`
|
|
1636
|
-
};
|
|
1637
|
-
}
|
|
1638
|
-
case "logs": {
|
|
1639
|
-
if (!input.terminalId) {
|
|
1640
|
-
return { success: false, error: 'logs requires a "terminalId" parameter' };
|
|
1641
|
-
}
|
|
1642
|
-
const result = manager.getLogs(input.terminalId, input.tail);
|
|
1643
|
-
if (!result) {
|
|
1644
|
-
return {
|
|
1645
|
-
success: false,
|
|
1646
|
-
error: `Terminal not found: ${input.terminalId}`
|
|
1647
|
-
};
|
|
1648
|
-
}
|
|
1649
|
-
return {
|
|
1650
|
-
success: true,
|
|
1651
|
-
terminalId: input.terminalId,
|
|
1652
|
-
logs: result.logs,
|
|
1653
|
-
lineCount: result.lineCount
|
|
1654
|
-
};
|
|
1655
|
-
}
|
|
1656
|
-
case "status": {
|
|
1657
|
-
if (!input.terminalId) {
|
|
1658
|
-
return { success: false, error: 'status requires a "terminalId" parameter' };
|
|
1659
|
-
}
|
|
1660
|
-
const status = manager.getStatus(input.terminalId);
|
|
1661
|
-
if (!status) {
|
|
1662
|
-
return {
|
|
1663
|
-
success: false,
|
|
1664
|
-
error: `Terminal not found: ${input.terminalId}`
|
|
1665
|
-
};
|
|
1666
|
-
}
|
|
1667
|
-
return {
|
|
1668
|
-
success: true,
|
|
1669
|
-
terminal: formatTerminal(status)
|
|
1670
|
-
};
|
|
1671
|
-
}
|
|
1672
|
-
case "kill": {
|
|
1673
|
-
if (!input.terminalId) {
|
|
1674
|
-
return { success: false, error: 'kill requires a "terminalId" parameter' };
|
|
1675
|
-
}
|
|
1676
|
-
const success = manager.kill(input.terminalId, input.signal);
|
|
1677
|
-
if (!success) {
|
|
1678
|
-
return {
|
|
1679
|
-
success: false,
|
|
1680
|
-
error: `Failed to kill terminal: ${input.terminalId}`
|
|
1681
|
-
};
|
|
1682
|
-
}
|
|
1683
|
-
return {
|
|
1684
|
-
success: true,
|
|
1685
|
-
message: `Sent ${input.signal || "SIGTERM"} to terminal ${input.terminalId}`
|
|
1686
|
-
};
|
|
1687
|
-
}
|
|
1688
|
-
case "write": {
|
|
1689
|
-
if (!input.terminalId) {
|
|
1690
|
-
return { success: false, error: 'write requires a "terminalId" parameter' };
|
|
1691
|
-
}
|
|
1692
|
-
if (!input.input) {
|
|
1693
|
-
return { success: false, error: 'write requires an "input" parameter' };
|
|
1694
|
-
}
|
|
1695
|
-
const success = manager.write(input.terminalId, input.input);
|
|
1696
|
-
if (!success) {
|
|
1697
|
-
return {
|
|
1698
|
-
success: false,
|
|
1699
|
-
error: `Failed to write to terminal: ${input.terminalId}`
|
|
1700
|
-
};
|
|
1701
|
-
}
|
|
1702
|
-
return {
|
|
1703
|
-
success: true,
|
|
1704
|
-
message: `Sent input to terminal ${input.terminalId}`
|
|
1705
|
-
};
|
|
1706
|
-
}
|
|
1707
|
-
case "list": {
|
|
1708
|
-
const terminals3 = manager.list(sessionId);
|
|
1709
|
-
return {
|
|
1710
|
-
success: true,
|
|
1711
|
-
terminals: terminals3.map(formatTerminal),
|
|
1712
|
-
count: terminals3.length,
|
|
1713
|
-
running: terminals3.filter((t) => t.status === "running").length
|
|
1714
|
-
};
|
|
1715
|
-
}
|
|
1716
|
-
default:
|
|
1717
|
-
return { success: false, error: `Unknown action: ${input.action}` };
|
|
1718
|
-
}
|
|
1719
|
-
}
|
|
1720
|
-
});
|
|
1721
|
-
}
|
|
1722
|
-
function formatTerminal(t) {
|
|
1723
|
-
return {
|
|
1724
|
-
id: t.id,
|
|
1725
|
-
name: t.name,
|
|
1726
|
-
command: t.command,
|
|
1727
|
-
cwd: t.cwd,
|
|
1728
|
-
pid: t.pid,
|
|
1729
|
-
status: t.status,
|
|
1730
|
-
exitCode: t.exitCode,
|
|
1731
|
-
error: t.error,
|
|
1732
|
-
createdAt: t.createdAt.toISOString(),
|
|
1733
|
-
stoppedAt: t.stoppedAt?.toISOString() || null
|
|
1734
|
-
};
|
|
1735
|
-
}
|
|
1736
|
-
|
|
1737
2273
|
// src/tools/index.ts
|
|
1738
2274
|
function createTools(options) {
|
|
1739
2275
|
return {
|
|
1740
2276
|
bash: createBashTool({
|
|
1741
2277
|
workingDirectory: options.workingDirectory,
|
|
1742
|
-
|
|
2278
|
+
sessionId: options.sessionId,
|
|
2279
|
+
onOutput: options.onBashOutput,
|
|
2280
|
+
onProgress: options.onBashProgress
|
|
1743
2281
|
}),
|
|
1744
2282
|
read_file: createReadFileTool({
|
|
1745
2283
|
workingDirectory: options.workingDirectory
|
|
1746
2284
|
}),
|
|
1747
2285
|
write_file: createWriteFileTool({
|
|
1748
|
-
workingDirectory: options.workingDirectory
|
|
2286
|
+
workingDirectory: options.workingDirectory,
|
|
2287
|
+
sessionId: options.sessionId
|
|
1749
2288
|
}),
|
|
1750
2289
|
todo: createTodoTool({
|
|
1751
2290
|
sessionId: options.sessionId
|
|
@@ -1753,10 +2292,6 @@ function createTools(options) {
|
|
|
1753
2292
|
load_skill: createLoadSkillTool({
|
|
1754
2293
|
sessionId: options.sessionId,
|
|
1755
2294
|
skillsDirectories: options.skillsDirectories
|
|
1756
|
-
}),
|
|
1757
|
-
terminal: createTerminalTool({
|
|
1758
|
-
sessionId: options.sessionId,
|
|
1759
|
-
workingDirectory: options.workingDirectory
|
|
1760
2295
|
})
|
|
1761
2296
|
};
|
|
1762
2297
|
}
|
|
@@ -1766,25 +2301,102 @@ import { generateText } from "ai";
|
|
|
1766
2301
|
import { gateway } from "@ai-sdk/gateway";
|
|
1767
2302
|
|
|
1768
2303
|
// src/agent/prompts.ts
|
|
2304
|
+
import os from "os";
|
|
2305
|
+
function getSearchInstructions() {
|
|
2306
|
+
const platform3 = process.platform;
|
|
2307
|
+
const common = `- **Prefer \`read_file\` over shell commands** for reading files - don't use \`cat\`, \`head\`, or \`tail\` when \`read_file\` is available
|
|
2308
|
+
- **Avoid unbounded searches** - always scope searches with glob patterns and directory paths to prevent overwhelming output
|
|
2309
|
+
- **Search strategically**: Start with specific patterns and directories, then broaden only if needed`;
|
|
2310
|
+
if (platform3 === "win32") {
|
|
2311
|
+
return `${common}
|
|
2312
|
+
- **Find files**: \`dir /s /b *.ts\` or PowerShell: \`Get-ChildItem -Recurse -Filter *.ts\`
|
|
2313
|
+
- **Search content**: \`findstr /s /n "pattern" *.ts\` or PowerShell: \`Select-String -Pattern "pattern" -Path *.ts -Recurse\`
|
|
2314
|
+
- **If ripgrep (\`rg\`) is installed**: \`rg "pattern" -t ts src/\` - faster and respects .gitignore`;
|
|
2315
|
+
}
|
|
2316
|
+
return `${common}
|
|
2317
|
+
- **Find files**: \`find . -name "*.ts"\` or \`find src/ -type f -name "*.tsx"\`
|
|
2318
|
+
- **Search content**: \`grep -rn "pattern" --include="*.ts" src/\` - use \`-l\` for filenames only, \`-c\` for counts
|
|
2319
|
+
- **If ripgrep (\`rg\`) is installed**: \`rg "pattern" -t ts src/\` - faster and respects .gitignore`;
|
|
2320
|
+
}
|
|
1769
2321
|
async function buildSystemPrompt(options) {
|
|
1770
2322
|
const { workingDirectory, skillsDirectories, sessionId, customInstructions } = options;
|
|
1771
2323
|
const skills = await loadAllSkills(skillsDirectories);
|
|
1772
2324
|
const skillsContext = formatSkillsForContext(skills);
|
|
1773
2325
|
const todos = todoQueries.getBySession(sessionId);
|
|
1774
2326
|
const todosContext = formatTodosForContext(todos);
|
|
1775
|
-
const
|
|
2327
|
+
const platform3 = process.platform === "win32" ? "Windows" : process.platform === "darwin" ? "macOS" : "Linux";
|
|
2328
|
+
const currentDate = (/* @__PURE__ */ new Date()).toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" });
|
|
2329
|
+
const searchInstructions = getSearchInstructions();
|
|
2330
|
+
const systemPrompt = `You are SparkECoder, an expert AI coding assistant. You help developers write, debug, and improve code.
|
|
1776
2331
|
|
|
1777
|
-
##
|
|
1778
|
-
|
|
2332
|
+
## Environment
|
|
2333
|
+
- **Platform**: ${platform3} (${os.release()})
|
|
2334
|
+
- **Date**: ${currentDate}
|
|
2335
|
+
- **Working Directory**: ${workingDirectory}
|
|
1779
2336
|
|
|
1780
2337
|
## Core Capabilities
|
|
1781
2338
|
You have access to powerful tools for:
|
|
1782
|
-
- **bash**: Execute
|
|
2339
|
+
- **bash**: Execute commands in the terminal (see below for details)
|
|
1783
2340
|
- **read_file**: Read file contents to understand code and context
|
|
1784
2341
|
- **write_file**: Create new files or edit existing ones (supports targeted string replacement)
|
|
1785
2342
|
- **todo**: Manage your task list to track progress on complex operations
|
|
1786
2343
|
- **load_skill**: Load specialized knowledge documents for specific tasks
|
|
1787
2344
|
|
|
2345
|
+
|
|
2346
|
+
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.
|
|
2347
|
+
|
|
2348
|
+
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.
|
|
2349
|
+
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.
|
|
2350
|
+
You can clear the todo and restart it, and do multiple things inside of one session.
|
|
2351
|
+
|
|
2352
|
+
### bash Tool
|
|
2353
|
+
The bash tool runs commands in the terminal. Every command runs in its own session with logs saved to disk.
|
|
2354
|
+
|
|
2355
|
+
**Run a command (default - waits for completion):**
|
|
2356
|
+
\`\`\`
|
|
2357
|
+
bash({ command: "npm install" })
|
|
2358
|
+
bash({ command: "git status" })
|
|
2359
|
+
\`\`\`
|
|
2360
|
+
|
|
2361
|
+
**Run in background (for dev servers, watchers):**
|
|
2362
|
+
\`\`\`
|
|
2363
|
+
bash({ command: "npm run dev", background: true })
|
|
2364
|
+
\u2192 Returns { id: "abc123" } - save this ID to check logs or stop it later
|
|
2365
|
+
\`\`\`
|
|
2366
|
+
|
|
2367
|
+
**Check on a background process:**
|
|
2368
|
+
\`\`\`
|
|
2369
|
+
bash({ id: "abc123" }) // get full output
|
|
2370
|
+
bash({ id: "abc123", tail: 50 }) // last 50 lines only
|
|
2371
|
+
\`\`\`
|
|
2372
|
+
|
|
2373
|
+
**Stop a background process:**
|
|
2374
|
+
\`\`\`
|
|
2375
|
+
bash({ id: "abc123", kill: true })
|
|
2376
|
+
\`\`\`
|
|
2377
|
+
|
|
2378
|
+
**Respond to interactive prompts (for yes/no questions, etc.):**
|
|
2379
|
+
\`\`\`
|
|
2380
|
+
bash({ id: "abc123", key: "y" }) // send 'y' for yes
|
|
2381
|
+
bash({ id: "abc123", key: "n" }) // send 'n' for no
|
|
2382
|
+
bash({ id: "abc123", key: "Enter" }) // press Enter
|
|
2383
|
+
bash({ id: "abc123", input: "my text" }) // send text input
|
|
2384
|
+
\`\`\`
|
|
2385
|
+
|
|
2386
|
+
**IMPORTANT - Handling Interactive Commands:**
|
|
2387
|
+
- ALWAYS prefer non-interactive flags when available:
|
|
2388
|
+
- \`npm init --yes\` or \`npm install --yes\`
|
|
2389
|
+
- \`npx create-next-app --yes\` (accepts all defaults)
|
|
2390
|
+
- \`npx create-react-app --yes\`
|
|
2391
|
+
- \`git commit --no-edit\`
|
|
2392
|
+
- \`apt-get install -y\`
|
|
2393
|
+
- If a command might prompt for input, run it in background mode first
|
|
2394
|
+
- Check the output to see if it's waiting for input
|
|
2395
|
+
- Use \`key: "y"\` or \`key: "n"\` for yes/no prompts
|
|
2396
|
+
- Use \`input: "text"\` for text input prompts
|
|
2397
|
+
|
|
2398
|
+
Logs are saved to \`.sparkecoder/terminals/{id}/output.log\` and can be read with \`read_file\` if needed.
|
|
2399
|
+
|
|
1788
2400
|
## Guidelines
|
|
1789
2401
|
|
|
1790
2402
|
### Code Quality
|
|
@@ -1805,6 +2417,30 @@ You have access to powerful tools for:
|
|
|
1805
2417
|
- Use \`write_file\` with mode "full" only for new files or complete rewrites
|
|
1806
2418
|
- Always verify changes by reading files after modifications
|
|
1807
2419
|
|
|
2420
|
+
### Searching and Exploration
|
|
2421
|
+
${searchInstructions}
|
|
2422
|
+
|
|
2423
|
+
Follow these principles when designing and implementing software:
|
|
2424
|
+
|
|
2425
|
+
1. **Modularity** \u2014 Write simple parts connected by clean interfaces
|
|
2426
|
+
2. **Clarity** \u2014 Clarity is better than cleverness
|
|
2427
|
+
3. **Composition** \u2014 Design programs to be connected to other programs
|
|
2428
|
+
4. **Separation** \u2014 Separate policy from mechanism; separate interfaces from engines
|
|
2429
|
+
5. **Simplicity** \u2014 Design for simplicity; add complexity only where you must
|
|
2430
|
+
6. **Parsimony** \u2014 Write a big program only when it is clear by demonstration that nothing else will do
|
|
2431
|
+
7. **Transparency** \u2014 Design for visibility to make inspection and debugging easier
|
|
2432
|
+
8. **Robustness** \u2014 Robustness is the child of transparency and simplicity
|
|
2433
|
+
9. **Representation** \u2014 Fold knowledge into data so program logic can be stupid and robust
|
|
2434
|
+
10. **Least Surprise** \u2014 In interface design, always do the least surprising thing
|
|
2435
|
+
11. **Silence** \u2014 When a program has nothing surprising to say, it should say nothing
|
|
2436
|
+
12. **Repair** \u2014 When you must fail, fail noisily and as soon as possible
|
|
2437
|
+
13. **Economy** \u2014 Programmer time is expensive; conserve it in preference to machine time
|
|
2438
|
+
14. **Generation** \u2014 Avoid hand-hacking; write programs to write programs when you can
|
|
2439
|
+
15. **Optimization** \u2014 Prototype before polishing. Get it working before you optimize it
|
|
2440
|
+
16. **Diversity** \u2014 Distrust all claims for "one true way"
|
|
2441
|
+
17. **Extensibility** \u2014 Design for the future, because it will be here sooner than you think
|
|
2442
|
+
|
|
2443
|
+
|
|
1808
2444
|
### Communication
|
|
1809
2445
|
- Explain your reasoning and approach
|
|
1810
2446
|
- Be concise but thorough
|
|
@@ -1961,12 +2597,24 @@ var approvalResolvers = /* @__PURE__ */ new Map();
|
|
|
1961
2597
|
var Agent = class _Agent {
|
|
1962
2598
|
session;
|
|
1963
2599
|
context;
|
|
1964
|
-
|
|
2600
|
+
baseTools;
|
|
1965
2601
|
pendingApprovals = /* @__PURE__ */ new Map();
|
|
1966
2602
|
constructor(session, context, tools) {
|
|
1967
2603
|
this.session = session;
|
|
1968
2604
|
this.context = context;
|
|
1969
|
-
this.
|
|
2605
|
+
this.baseTools = tools;
|
|
2606
|
+
}
|
|
2607
|
+
/**
|
|
2608
|
+
* Create tools with optional progress callbacks
|
|
2609
|
+
*/
|
|
2610
|
+
createToolsWithCallbacks(options) {
|
|
2611
|
+
const config = getConfig();
|
|
2612
|
+
return createTools({
|
|
2613
|
+
sessionId: this.session.id,
|
|
2614
|
+
workingDirectory: this.session.workingDirectory,
|
|
2615
|
+
skillsDirectories: config.resolvedSkillsDirectories,
|
|
2616
|
+
onBashProgress: options.onToolProgress ? (progress) => options.onToolProgress({ toolName: "bash", data: progress }) : void 0
|
|
2617
|
+
});
|
|
1970
2618
|
}
|
|
1971
2619
|
/**
|
|
1972
2620
|
* Create or resume an agent session
|
|
@@ -2018,7 +2666,9 @@ var Agent = class _Agent {
|
|
|
2018
2666
|
*/
|
|
2019
2667
|
async stream(options) {
|
|
2020
2668
|
const config = getConfig();
|
|
2021
|
-
|
|
2669
|
+
if (!options.skipSaveUserMessage) {
|
|
2670
|
+
this.context.addUserMessage(options.prompt);
|
|
2671
|
+
}
|
|
2022
2672
|
sessionQueries.updateStatus(this.session.id, "active");
|
|
2023
2673
|
const systemPrompt = await buildSystemPrompt({
|
|
2024
2674
|
workingDirectory: this.session.workingDirectory,
|
|
@@ -2026,15 +2676,30 @@ var Agent = class _Agent {
|
|
|
2026
2676
|
sessionId: this.session.id
|
|
2027
2677
|
});
|
|
2028
2678
|
const messages2 = await this.context.getMessages();
|
|
2029
|
-
const
|
|
2679
|
+
const tools = options.onToolProgress ? this.createToolsWithCallbacks({ onToolProgress: options.onToolProgress }) : this.baseTools;
|
|
2680
|
+
const wrappedTools = this.wrapToolsWithApproval(options, tools);
|
|
2030
2681
|
const stream = streamText({
|
|
2031
2682
|
model: gateway2(this.session.model),
|
|
2032
2683
|
system: systemPrompt,
|
|
2033
2684
|
messages: messages2,
|
|
2034
2685
|
tools: wrappedTools,
|
|
2035
|
-
stopWhen: stepCountIs(
|
|
2686
|
+
stopWhen: stepCountIs(500),
|
|
2687
|
+
// Forward abort signal if provided
|
|
2688
|
+
abortSignal: options.abortSignal,
|
|
2689
|
+
// Enable extended thinking/reasoning for models that support it
|
|
2690
|
+
providerOptions: {
|
|
2691
|
+
anthropic: {
|
|
2692
|
+
thinking: {
|
|
2693
|
+
type: "enabled",
|
|
2694
|
+
budgetTokens: 1e4
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
},
|
|
2036
2698
|
onStepFinish: async (step) => {
|
|
2037
2699
|
options.onStepFinish?.(step);
|
|
2700
|
+
},
|
|
2701
|
+
onAbort: ({ steps }) => {
|
|
2702
|
+
options.onAbort?.({ steps });
|
|
2038
2703
|
}
|
|
2039
2704
|
});
|
|
2040
2705
|
const saveResponseMessages = async () => {
|
|
@@ -2062,13 +2727,23 @@ var Agent = class _Agent {
|
|
|
2062
2727
|
sessionId: this.session.id
|
|
2063
2728
|
});
|
|
2064
2729
|
const messages2 = await this.context.getMessages();
|
|
2065
|
-
const
|
|
2730
|
+
const tools = options.onToolProgress ? this.createToolsWithCallbacks({ onToolProgress: options.onToolProgress }) : this.baseTools;
|
|
2731
|
+
const wrappedTools = this.wrapToolsWithApproval(options, tools);
|
|
2066
2732
|
const result = await generateText2({
|
|
2067
2733
|
model: gateway2(this.session.model),
|
|
2068
2734
|
system: systemPrompt,
|
|
2069
2735
|
messages: messages2,
|
|
2070
2736
|
tools: wrappedTools,
|
|
2071
|
-
stopWhen: stepCountIs(
|
|
2737
|
+
stopWhen: stepCountIs(500),
|
|
2738
|
+
// Enable extended thinking/reasoning for models that support it
|
|
2739
|
+
providerOptions: {
|
|
2740
|
+
anthropic: {
|
|
2741
|
+
thinking: {
|
|
2742
|
+
type: "enabled",
|
|
2743
|
+
budgetTokens: 1e4
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2072
2747
|
});
|
|
2073
2748
|
const responseMessages = result.response.messages;
|
|
2074
2749
|
this.context.addResponseMessages(responseMessages);
|
|
@@ -2080,20 +2755,21 @@ var Agent = class _Agent {
|
|
|
2080
2755
|
/**
|
|
2081
2756
|
* Wrap tools to add approval checking
|
|
2082
2757
|
*/
|
|
2083
|
-
wrapToolsWithApproval(options) {
|
|
2758
|
+
wrapToolsWithApproval(options, tools) {
|
|
2084
2759
|
const sessionConfig = this.session.config;
|
|
2085
2760
|
const wrappedTools = {};
|
|
2086
|
-
|
|
2761
|
+
const toolsToWrap = tools || this.baseTools;
|
|
2762
|
+
for (const [name, originalTool] of Object.entries(toolsToWrap)) {
|
|
2087
2763
|
const needsApproval = requiresApproval(name, sessionConfig ?? void 0);
|
|
2088
2764
|
if (!needsApproval) {
|
|
2089
2765
|
wrappedTools[name] = originalTool;
|
|
2090
2766
|
continue;
|
|
2091
2767
|
}
|
|
2092
|
-
wrappedTools[name] =
|
|
2768
|
+
wrappedTools[name] = tool6({
|
|
2093
2769
|
description: originalTool.description || "",
|
|
2094
|
-
inputSchema: originalTool.inputSchema ||
|
|
2770
|
+
inputSchema: originalTool.inputSchema || z7.object({}),
|
|
2095
2771
|
execute: async (input, toolOptions) => {
|
|
2096
|
-
const toolCallId = toolOptions.toolCallId ||
|
|
2772
|
+
const toolCallId = toolOptions.toolCallId || nanoid3();
|
|
2097
2773
|
const execution = toolExecutionQueries.create({
|
|
2098
2774
|
sessionId: this.session.id,
|
|
2099
2775
|
toolName: name,
|
|
@@ -2105,8 +2781,8 @@ var Agent = class _Agent {
|
|
|
2105
2781
|
this.pendingApprovals.set(toolCallId, execution);
|
|
2106
2782
|
options.onApprovalRequired?.(execution);
|
|
2107
2783
|
sessionQueries.updateStatus(this.session.id, "waiting");
|
|
2108
|
-
const approved = await new Promise((
|
|
2109
|
-
approvalResolvers.set(toolCallId, { resolve:
|
|
2784
|
+
const approved = await new Promise((resolve8) => {
|
|
2785
|
+
approvalResolvers.set(toolCallId, { resolve: resolve8, sessionId: this.session.id });
|
|
2110
2786
|
});
|
|
2111
2787
|
const resolverData = approvalResolvers.get(toolCallId);
|
|
2112
2788
|
approvalResolvers.delete(toolCallId);
|
|
@@ -2201,18 +2877,18 @@ var Agent = class _Agent {
|
|
|
2201
2877
|
|
|
2202
2878
|
// src/server/routes/sessions.ts
|
|
2203
2879
|
var sessions2 = new Hono();
|
|
2204
|
-
var createSessionSchema =
|
|
2205
|
-
name:
|
|
2206
|
-
workingDirectory:
|
|
2207
|
-
model:
|
|
2208
|
-
toolApprovals:
|
|
2880
|
+
var createSessionSchema = z8.object({
|
|
2881
|
+
name: z8.string().optional(),
|
|
2882
|
+
workingDirectory: z8.string().optional(),
|
|
2883
|
+
model: z8.string().optional(),
|
|
2884
|
+
toolApprovals: z8.record(z8.string(), z8.boolean()).optional()
|
|
2209
2885
|
});
|
|
2210
|
-
var paginationQuerySchema =
|
|
2211
|
-
limit:
|
|
2212
|
-
offset:
|
|
2886
|
+
var paginationQuerySchema = z8.object({
|
|
2887
|
+
limit: z8.string().optional(),
|
|
2888
|
+
offset: z8.string().optional()
|
|
2213
2889
|
});
|
|
2214
|
-
var messagesQuerySchema =
|
|
2215
|
-
limit:
|
|
2890
|
+
var messagesQuerySchema = z8.object({
|
|
2891
|
+
limit: z8.string().optional()
|
|
2216
2892
|
});
|
|
2217
2893
|
sessions2.get(
|
|
2218
2894
|
"/",
|
|
@@ -2222,16 +2898,22 @@ sessions2.get(
|
|
|
2222
2898
|
const limit = parseInt(query.limit || "50");
|
|
2223
2899
|
const offset = parseInt(query.offset || "0");
|
|
2224
2900
|
const allSessions = sessionQueries.list(limit, offset);
|
|
2225
|
-
|
|
2226
|
-
|
|
2901
|
+
const sessionsWithStreamInfo = allSessions.map((s) => {
|
|
2902
|
+
const activeStream = activeStreamQueries.getBySessionId(s.id);
|
|
2903
|
+
return {
|
|
2227
2904
|
id: s.id,
|
|
2228
2905
|
name: s.name,
|
|
2229
2906
|
workingDirectory: s.workingDirectory,
|
|
2230
2907
|
model: s.model,
|
|
2231
2908
|
status: s.status,
|
|
2909
|
+
config: s.config,
|
|
2910
|
+
isStreaming: !!activeStream,
|
|
2232
2911
|
createdAt: s.createdAt.toISOString(),
|
|
2233
2912
|
updatedAt: s.updatedAt.toISOString()
|
|
2234
|
-
}
|
|
2913
|
+
};
|
|
2914
|
+
});
|
|
2915
|
+
return c.json({
|
|
2916
|
+
sessions: sessionsWithStreamInfo,
|
|
2235
2917
|
count: allSessions.length,
|
|
2236
2918
|
limit,
|
|
2237
2919
|
offset
|
|
@@ -2345,13 +3027,63 @@ sessions2.get("/:id/tools", async (c) => {
|
|
|
2345
3027
|
count: executions.length
|
|
2346
3028
|
});
|
|
2347
3029
|
});
|
|
3030
|
+
var updateSessionSchema = z8.object({
|
|
3031
|
+
model: z8.string().optional(),
|
|
3032
|
+
name: z8.string().optional(),
|
|
3033
|
+
toolApprovals: z8.record(z8.string(), z8.boolean()).optional()
|
|
3034
|
+
});
|
|
3035
|
+
sessions2.patch(
|
|
3036
|
+
"/:id",
|
|
3037
|
+
zValidator("json", updateSessionSchema),
|
|
3038
|
+
async (c) => {
|
|
3039
|
+
const id = c.req.param("id");
|
|
3040
|
+
const body = c.req.valid("json");
|
|
3041
|
+
const session = sessionQueries.getById(id);
|
|
3042
|
+
if (!session) {
|
|
3043
|
+
return c.json({ error: "Session not found" }, 404);
|
|
3044
|
+
}
|
|
3045
|
+
const updates = {};
|
|
3046
|
+
if (body.model) updates.model = body.model;
|
|
3047
|
+
if (body.name !== void 0) updates.name = body.name;
|
|
3048
|
+
if (body.toolApprovals !== void 0) {
|
|
3049
|
+
const existingConfig = session.config || {};
|
|
3050
|
+
const existingToolApprovals = existingConfig.toolApprovals || {};
|
|
3051
|
+
updates.config = {
|
|
3052
|
+
...existingConfig,
|
|
3053
|
+
toolApprovals: {
|
|
3054
|
+
...existingToolApprovals,
|
|
3055
|
+
...body.toolApprovals
|
|
3056
|
+
}
|
|
3057
|
+
};
|
|
3058
|
+
}
|
|
3059
|
+
const updatedSession = Object.keys(updates).length > 0 ? sessionQueries.update(id, updates) || session : session;
|
|
3060
|
+
return c.json({
|
|
3061
|
+
id: updatedSession.id,
|
|
3062
|
+
name: updatedSession.name,
|
|
3063
|
+
model: updatedSession.model,
|
|
3064
|
+
status: updatedSession.status,
|
|
3065
|
+
workingDirectory: updatedSession.workingDirectory,
|
|
3066
|
+
config: updatedSession.config,
|
|
3067
|
+
updatedAt: updatedSession.updatedAt.toISOString()
|
|
3068
|
+
});
|
|
3069
|
+
}
|
|
3070
|
+
);
|
|
2348
3071
|
sessions2.delete("/:id", async (c) => {
|
|
2349
3072
|
const id = c.req.param("id");
|
|
2350
3073
|
try {
|
|
2351
|
-
const
|
|
2352
|
-
|
|
3074
|
+
const session = sessionQueries.getById(id);
|
|
3075
|
+
if (session) {
|
|
3076
|
+
const terminalIds = await listSessions();
|
|
3077
|
+
for (const tid of terminalIds) {
|
|
3078
|
+
const meta = await getMeta(tid, session.workingDirectory);
|
|
3079
|
+
if (meta && meta.sessionId === id) {
|
|
3080
|
+
await killTerminal(tid);
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
2353
3084
|
} catch (e) {
|
|
2354
3085
|
}
|
|
3086
|
+
clearCheckpointManager(id);
|
|
2355
3087
|
const deleted = sessionQueries.delete(id);
|
|
2356
3088
|
if (!deleted) {
|
|
2357
3089
|
return c.json({ error: "Session not found" }, 404);
|
|
@@ -2368,160 +3100,488 @@ sessions2.post("/:id/clear", async (c) => {
|
|
|
2368
3100
|
agent.clearContext();
|
|
2369
3101
|
return c.json({ success: true, sessionId: id });
|
|
2370
3102
|
});
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
3103
|
+
sessions2.get("/:id/todos", async (c) => {
|
|
3104
|
+
const id = c.req.param("id");
|
|
3105
|
+
const session = sessionQueries.getById(id);
|
|
3106
|
+
if (!session) {
|
|
3107
|
+
return c.json({ error: "Session not found" }, 404);
|
|
3108
|
+
}
|
|
3109
|
+
const todos = todoQueries.getBySession(id);
|
|
3110
|
+
const pending = todos.filter((t) => t.status === "pending");
|
|
3111
|
+
const inProgress = todos.filter((t) => t.status === "in_progress");
|
|
3112
|
+
const completed = todos.filter((t) => t.status === "completed");
|
|
3113
|
+
const cancelled = todos.filter((t) => t.status === "cancelled");
|
|
3114
|
+
const nextTodo = inProgress[0] || pending[0] || null;
|
|
3115
|
+
return c.json({
|
|
3116
|
+
todos: todos.map((t) => ({
|
|
3117
|
+
id: t.id,
|
|
3118
|
+
content: t.content,
|
|
3119
|
+
status: t.status,
|
|
3120
|
+
order: t.order,
|
|
3121
|
+
createdAt: t.createdAt.toISOString(),
|
|
3122
|
+
updatedAt: t.updatedAt.toISOString()
|
|
3123
|
+
})),
|
|
3124
|
+
stats: {
|
|
3125
|
+
total: todos.length,
|
|
3126
|
+
pending: pending.length,
|
|
3127
|
+
inProgress: inProgress.length,
|
|
3128
|
+
completed: completed.length,
|
|
3129
|
+
cancelled: cancelled.length
|
|
3130
|
+
},
|
|
3131
|
+
nextTodo: nextTodo ? {
|
|
3132
|
+
id: nextTodo.id,
|
|
3133
|
+
content: nextTodo.content,
|
|
3134
|
+
status: nextTodo.status
|
|
3135
|
+
} : null
|
|
3136
|
+
});
|
|
2380
3137
|
});
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
3138
|
+
sessions2.get("/:id/checkpoints", async (c) => {
|
|
3139
|
+
const id = c.req.param("id");
|
|
3140
|
+
const session = sessionQueries.getById(id);
|
|
3141
|
+
if (!session) {
|
|
3142
|
+
return c.json({ error: "Session not found" }, 404);
|
|
3143
|
+
}
|
|
3144
|
+
const checkpoints2 = getCheckpoints(id);
|
|
3145
|
+
return c.json({
|
|
3146
|
+
sessionId: id,
|
|
3147
|
+
checkpoints: checkpoints2.map((cp) => ({
|
|
3148
|
+
id: cp.id,
|
|
3149
|
+
messageSequence: cp.messageSequence,
|
|
3150
|
+
gitHead: cp.gitHead,
|
|
3151
|
+
createdAt: cp.createdAt.toISOString()
|
|
3152
|
+
})),
|
|
3153
|
+
count: checkpoints2.length
|
|
3154
|
+
});
|
|
2387
3155
|
});
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
3156
|
+
sessions2.post("/:id/revert/:checkpointId", async (c) => {
|
|
3157
|
+
const sessionId = c.req.param("id");
|
|
3158
|
+
const checkpointId = c.req.param("checkpointId");
|
|
3159
|
+
const session = sessionQueries.getById(sessionId);
|
|
3160
|
+
if (!session) {
|
|
3161
|
+
return c.json({ error: "Session not found" }, 404);
|
|
3162
|
+
}
|
|
3163
|
+
const activeStream = activeStreamQueries.getBySessionId(sessionId);
|
|
3164
|
+
if (activeStream) {
|
|
3165
|
+
return c.json({
|
|
3166
|
+
error: "Cannot revert while a stream is active. Stop the stream first.",
|
|
3167
|
+
streamId: activeStream.streamId
|
|
3168
|
+
}, 409);
|
|
3169
|
+
}
|
|
3170
|
+
const result = await revertToCheckpoint(sessionId, checkpointId);
|
|
3171
|
+
if (!result.success) {
|
|
3172
|
+
return c.json({ error: result.error }, 400);
|
|
3173
|
+
}
|
|
3174
|
+
return c.json({
|
|
3175
|
+
success: true,
|
|
3176
|
+
sessionId,
|
|
3177
|
+
checkpointId,
|
|
3178
|
+
filesRestored: result.filesRestored,
|
|
3179
|
+
filesDeleted: result.filesDeleted,
|
|
3180
|
+
messagesDeleted: result.messagesDeleted,
|
|
3181
|
+
checkpointsDeleted: result.checkpointsDeleted
|
|
3182
|
+
});
|
|
3183
|
+
});
|
|
3184
|
+
sessions2.get("/:id/diff", async (c) => {
|
|
3185
|
+
const id = c.req.param("id");
|
|
3186
|
+
const session = sessionQueries.getById(id);
|
|
3187
|
+
if (!session) {
|
|
3188
|
+
return c.json({ error: "Session not found" }, 404);
|
|
3189
|
+
}
|
|
3190
|
+
const diff = await getSessionDiff(id);
|
|
3191
|
+
return c.json({
|
|
3192
|
+
sessionId: id,
|
|
3193
|
+
files: diff.files.map((f) => ({
|
|
3194
|
+
path: f.path,
|
|
3195
|
+
status: f.status,
|
|
3196
|
+
hasOriginal: f.originalContent !== null,
|
|
3197
|
+
hasCurrent: f.currentContent !== null
|
|
3198
|
+
// Optionally include content (can be large)
|
|
3199
|
+
// originalContent: f.originalContent,
|
|
3200
|
+
// currentContent: f.currentContent,
|
|
3201
|
+
})),
|
|
3202
|
+
summary: {
|
|
3203
|
+
created: diff.files.filter((f) => f.status === "created").length,
|
|
3204
|
+
modified: diff.files.filter((f) => f.status === "modified").length,
|
|
3205
|
+
deleted: diff.files.filter((f) => f.status === "deleted").length,
|
|
3206
|
+
total: diff.files.length
|
|
3207
|
+
}
|
|
3208
|
+
});
|
|
3209
|
+
});
|
|
3210
|
+
sessions2.get("/:id/diff/:filePath", async (c) => {
|
|
3211
|
+
const sessionId = c.req.param("id");
|
|
3212
|
+
const filePath = decodeURIComponent(c.req.param("filePath"));
|
|
3213
|
+
const session = sessionQueries.getById(sessionId);
|
|
3214
|
+
if (!session) {
|
|
3215
|
+
return c.json({ error: "Session not found" }, 404);
|
|
3216
|
+
}
|
|
3217
|
+
const diff = await getSessionDiff(sessionId);
|
|
3218
|
+
const fileDiff = diff.files.find((f) => f.path === filePath);
|
|
3219
|
+
if (!fileDiff) {
|
|
3220
|
+
return c.json({ error: "File not found in diff" }, 404);
|
|
3221
|
+
}
|
|
3222
|
+
return c.json({
|
|
3223
|
+
sessionId,
|
|
3224
|
+
path: fileDiff.path,
|
|
3225
|
+
status: fileDiff.status,
|
|
3226
|
+
originalContent: fileDiff.originalContent,
|
|
3227
|
+
currentContent: fileDiff.currentContent
|
|
3228
|
+
});
|
|
3229
|
+
});
|
|
3230
|
+
|
|
3231
|
+
// src/server/routes/agents.ts
|
|
3232
|
+
import { Hono as Hono2 } from "hono";
|
|
3233
|
+
import { zValidator as zValidator2 } from "@hono/zod-validator";
|
|
3234
|
+
import { z as z9 } from "zod";
|
|
3235
|
+
|
|
3236
|
+
// src/server/resumable-stream.ts
|
|
3237
|
+
import { createResumableStreamContext } from "resumable-stream/generic";
|
|
3238
|
+
var store = /* @__PURE__ */ new Map();
|
|
3239
|
+
var channels = /* @__PURE__ */ new Map();
|
|
3240
|
+
var cleanupInterval = setInterval(() => {
|
|
3241
|
+
const now = Date.now();
|
|
3242
|
+
for (const [key, data] of store.entries()) {
|
|
3243
|
+
if (data.expiresAt && data.expiresAt < now) {
|
|
3244
|
+
store.delete(key);
|
|
2400
3245
|
}
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
3246
|
+
}
|
|
3247
|
+
}, 6e4);
|
|
3248
|
+
cleanupInterval.unref();
|
|
3249
|
+
var publisher = {
|
|
3250
|
+
connect: async () => {
|
|
3251
|
+
},
|
|
3252
|
+
publish: async (channel, message) => {
|
|
3253
|
+
const subscribers = channels.get(channel);
|
|
3254
|
+
if (subscribers) {
|
|
3255
|
+
for (const callback of subscribers) {
|
|
3256
|
+
setImmediate(() => callback(message));
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
},
|
|
3260
|
+
set: async (key, value, options) => {
|
|
3261
|
+
const expiresAt = options?.EX ? Date.now() + options.EX * 1e3 : void 0;
|
|
3262
|
+
store.set(key, { value, expiresAt });
|
|
3263
|
+
if (options?.EX) {
|
|
3264
|
+
setTimeout(() => store.delete(key), options.EX * 1e3);
|
|
3265
|
+
}
|
|
3266
|
+
},
|
|
3267
|
+
get: async (key) => {
|
|
3268
|
+
const data = store.get(key);
|
|
3269
|
+
if (!data) return null;
|
|
3270
|
+
if (data.expiresAt && data.expiresAt < Date.now()) {
|
|
3271
|
+
store.delete(key);
|
|
3272
|
+
return null;
|
|
3273
|
+
}
|
|
3274
|
+
return data.value;
|
|
3275
|
+
},
|
|
3276
|
+
incr: async (key) => {
|
|
3277
|
+
const data = store.get(key);
|
|
3278
|
+
const current = data ? parseInt(data.value, 10) : 0;
|
|
3279
|
+
const next = (isNaN(current) ? 0 : current) + 1;
|
|
3280
|
+
store.set(key, { value: String(next), expiresAt: data?.expiresAt });
|
|
3281
|
+
return next;
|
|
3282
|
+
}
|
|
3283
|
+
};
|
|
3284
|
+
var subscriber = {
|
|
3285
|
+
connect: async () => {
|
|
3286
|
+
},
|
|
3287
|
+
subscribe: async (channel, callback) => {
|
|
3288
|
+
if (!channels.has(channel)) {
|
|
3289
|
+
channels.set(channel, /* @__PURE__ */ new Set());
|
|
3290
|
+
}
|
|
3291
|
+
channels.get(channel).add(callback);
|
|
3292
|
+
},
|
|
3293
|
+
unsubscribe: async (channel) => {
|
|
3294
|
+
channels.delete(channel);
|
|
3295
|
+
}
|
|
3296
|
+
};
|
|
3297
|
+
var streamContext = createResumableStreamContext({
|
|
3298
|
+
// Background task handler - just let promises run and log errors
|
|
3299
|
+
waitUntil: (promise) => {
|
|
3300
|
+
promise.catch((err) => {
|
|
3301
|
+
console.error("[ResumableStream] Background task error:", err);
|
|
3302
|
+
});
|
|
3303
|
+
},
|
|
3304
|
+
publisher,
|
|
3305
|
+
subscriber
|
|
3306
|
+
});
|
|
3307
|
+
|
|
3308
|
+
// src/server/routes/agents.ts
|
|
3309
|
+
import { nanoid as nanoid4 } from "nanoid";
|
|
3310
|
+
var agents = new Hono2();
|
|
3311
|
+
var runPromptSchema = z9.object({
|
|
3312
|
+
prompt: z9.string().min(1)
|
|
3313
|
+
});
|
|
3314
|
+
var quickStartSchema = z9.object({
|
|
3315
|
+
prompt: z9.string().min(1),
|
|
3316
|
+
name: z9.string().optional(),
|
|
3317
|
+
workingDirectory: z9.string().optional(),
|
|
3318
|
+
model: z9.string().optional(),
|
|
3319
|
+
toolApprovals: z9.record(z9.string(), z9.boolean()).optional()
|
|
3320
|
+
});
|
|
3321
|
+
var rejectSchema = z9.object({
|
|
3322
|
+
reason: z9.string().optional()
|
|
3323
|
+
}).optional();
|
|
3324
|
+
var streamAbortControllers = /* @__PURE__ */ new Map();
|
|
3325
|
+
function createAgentStreamProducer(sessionId, prompt, streamId) {
|
|
3326
|
+
return () => {
|
|
3327
|
+
const { readable, writable } = new TransformStream();
|
|
3328
|
+
const writer = writable.getWriter();
|
|
3329
|
+
let writerClosed = false;
|
|
3330
|
+
const abortController = new AbortController();
|
|
3331
|
+
streamAbortControllers.set(streamId, abortController);
|
|
3332
|
+
const writeSSE = async (data) => {
|
|
3333
|
+
if (writerClosed) return;
|
|
2406
3334
|
try {
|
|
2407
|
-
|
|
3335
|
+
await writer.write(`data: ${data}
|
|
3336
|
+
|
|
3337
|
+
`);
|
|
3338
|
+
} catch (err) {
|
|
3339
|
+
writerClosed = true;
|
|
3340
|
+
}
|
|
3341
|
+
};
|
|
3342
|
+
const safeClose = async () => {
|
|
3343
|
+
if (writerClosed) return;
|
|
3344
|
+
try {
|
|
3345
|
+
writerClosed = true;
|
|
3346
|
+
await writer.close();
|
|
3347
|
+
} catch {
|
|
3348
|
+
}
|
|
3349
|
+
};
|
|
3350
|
+
const cleanupAbortController = () => {
|
|
3351
|
+
streamAbortControllers.delete(streamId);
|
|
3352
|
+
};
|
|
3353
|
+
(async () => {
|
|
3354
|
+
let isAborted = false;
|
|
3355
|
+
try {
|
|
3356
|
+
const agent = await Agent.create({ sessionId });
|
|
3357
|
+
await writeSSE(JSON.stringify({ type: "data-stream-id", streamId }));
|
|
3358
|
+
await writeSSE(JSON.stringify({
|
|
3359
|
+
type: "data-user-message",
|
|
3360
|
+
data: { id: `user_${Date.now()}`, content: prompt }
|
|
3361
|
+
}));
|
|
2408
3362
|
const messageId = `msg_${Date.now()}`;
|
|
2409
|
-
await
|
|
2410
|
-
data: JSON.stringify({ type: "start", messageId })
|
|
2411
|
-
});
|
|
3363
|
+
await writeSSE(JSON.stringify({ type: "start", messageId }));
|
|
2412
3364
|
let textId = `text_${Date.now()}`;
|
|
2413
3365
|
let textStarted = false;
|
|
2414
3366
|
const result = await agent.stream({
|
|
2415
3367
|
prompt,
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
data: JSON.stringify({
|
|
2426
|
-
type: "tool-input-available",
|
|
2427
|
-
toolCallId: toolCall.toolCallId,
|
|
2428
|
-
toolName: toolCall.toolName,
|
|
2429
|
-
input: toolCall.input
|
|
2430
|
-
})
|
|
2431
|
-
});
|
|
3368
|
+
abortSignal: abortController.signal,
|
|
3369
|
+
// Use our managed abort controller, NOT client signal
|
|
3370
|
+
skipSaveUserMessage: true,
|
|
3371
|
+
// User message is saved in the route before streaming
|
|
3372
|
+
// Note: tool-input-start/available events are sent from the stream loop
|
|
3373
|
+
// when we see tool-call-streaming-start and tool-call events.
|
|
3374
|
+
// We only use onToolCall/onToolResult for non-streaming scenarios or
|
|
3375
|
+
// tools that need special handling (like approval requests).
|
|
3376
|
+
onToolCall: async () => {
|
|
2432
3377
|
},
|
|
2433
|
-
onToolResult: async (
|
|
2434
|
-
await stream.writeSSE({
|
|
2435
|
-
data: JSON.stringify({
|
|
2436
|
-
type: "tool-output-available",
|
|
2437
|
-
toolCallId: result2.toolCallId,
|
|
2438
|
-
output: result2.output
|
|
2439
|
-
})
|
|
2440
|
-
});
|
|
3378
|
+
onToolResult: async () => {
|
|
2441
3379
|
},
|
|
2442
3380
|
onApprovalRequired: async (execution) => {
|
|
2443
|
-
await
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
3381
|
+
await writeSSE(JSON.stringify({
|
|
3382
|
+
type: "data-approval-required",
|
|
3383
|
+
data: {
|
|
3384
|
+
id: execution.id,
|
|
3385
|
+
toolCallId: execution.toolCallId,
|
|
3386
|
+
toolName: execution.toolName,
|
|
3387
|
+
input: execution.input
|
|
3388
|
+
}
|
|
3389
|
+
}));
|
|
3390
|
+
},
|
|
3391
|
+
onToolProgress: async (progress) => {
|
|
3392
|
+
await writeSSE(JSON.stringify({
|
|
3393
|
+
type: "tool-progress",
|
|
3394
|
+
toolName: progress.toolName,
|
|
3395
|
+
data: progress.data
|
|
3396
|
+
}));
|
|
2454
3397
|
},
|
|
2455
3398
|
onStepFinish: async () => {
|
|
2456
|
-
await
|
|
2457
|
-
data: JSON.stringify({ type: "finish-step" })
|
|
2458
|
-
});
|
|
3399
|
+
await writeSSE(JSON.stringify({ type: "finish-step" }));
|
|
2459
3400
|
if (textStarted) {
|
|
2460
|
-
await
|
|
2461
|
-
data: JSON.stringify({ type: "text-end", id: textId })
|
|
2462
|
-
});
|
|
3401
|
+
await writeSSE(JSON.stringify({ type: "text-end", id: textId }));
|
|
2463
3402
|
textStarted = false;
|
|
2464
3403
|
textId = `text_${Date.now()}`;
|
|
2465
3404
|
}
|
|
3405
|
+
},
|
|
3406
|
+
onAbort: async ({ steps }) => {
|
|
3407
|
+
isAborted = true;
|
|
3408
|
+
console.log(`Stream aborted after ${steps.length} steps`);
|
|
2466
3409
|
}
|
|
2467
3410
|
});
|
|
3411
|
+
let reasoningId = `reasoning_${Date.now()}`;
|
|
3412
|
+
let reasoningStarted = false;
|
|
2468
3413
|
for await (const part of result.stream.fullStream) {
|
|
2469
3414
|
if (part.type === "text-delta") {
|
|
2470
3415
|
if (!textStarted) {
|
|
2471
|
-
await
|
|
2472
|
-
data: JSON.stringify({ type: "text-start", id: textId })
|
|
2473
|
-
});
|
|
3416
|
+
await writeSSE(JSON.stringify({ type: "text-start", id: textId }));
|
|
2474
3417
|
textStarted = true;
|
|
2475
3418
|
}
|
|
2476
|
-
await
|
|
2477
|
-
|
|
2478
|
-
});
|
|
3419
|
+
await writeSSE(JSON.stringify({ type: "text-delta", id: textId, delta: part.text }));
|
|
3420
|
+
} else if (part.type === "reasoning-start") {
|
|
3421
|
+
await writeSSE(JSON.stringify({ type: "reasoning-start", id: reasoningId }));
|
|
3422
|
+
reasoningStarted = true;
|
|
3423
|
+
} else if (part.type === "reasoning-delta") {
|
|
3424
|
+
await writeSSE(JSON.stringify({ type: "reasoning-delta", id: reasoningId, delta: part.text }));
|
|
3425
|
+
} else if (part.type === "reasoning-end") {
|
|
3426
|
+
if (reasoningStarted) {
|
|
3427
|
+
await writeSSE(JSON.stringify({ type: "reasoning-end", id: reasoningId }));
|
|
3428
|
+
reasoningStarted = false;
|
|
3429
|
+
reasoningId = `reasoning_${Date.now()}`;
|
|
3430
|
+
}
|
|
3431
|
+
} else if (part.type === "tool-call-streaming-start") {
|
|
3432
|
+
const p = part;
|
|
3433
|
+
await writeSSE(JSON.stringify({
|
|
3434
|
+
type: "tool-input-start",
|
|
3435
|
+
toolCallId: p.toolCallId,
|
|
3436
|
+
toolName: p.toolName
|
|
3437
|
+
}));
|
|
3438
|
+
} else if (part.type === "tool-call-delta") {
|
|
3439
|
+
const p = part;
|
|
3440
|
+
await writeSSE(JSON.stringify({
|
|
3441
|
+
type: "tool-input-delta",
|
|
3442
|
+
toolCallId: p.toolCallId,
|
|
3443
|
+
argsTextDelta: p.argsTextDelta
|
|
3444
|
+
}));
|
|
2479
3445
|
} else if (part.type === "tool-call") {
|
|
2480
|
-
await
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
})
|
|
2487
|
-
});
|
|
3446
|
+
await writeSSE(JSON.stringify({
|
|
3447
|
+
type: "tool-input-available",
|
|
3448
|
+
toolCallId: part.toolCallId,
|
|
3449
|
+
toolName: part.toolName,
|
|
3450
|
+
input: part.input
|
|
3451
|
+
}));
|
|
2488
3452
|
} else if (part.type === "tool-result") {
|
|
2489
|
-
await
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
})
|
|
2495
|
-
});
|
|
3453
|
+
await writeSSE(JSON.stringify({
|
|
3454
|
+
type: "tool-output-available",
|
|
3455
|
+
toolCallId: part.toolCallId,
|
|
3456
|
+
output: part.output
|
|
3457
|
+
}));
|
|
2496
3458
|
} else if (part.type === "error") {
|
|
2497
3459
|
console.error("Stream error:", part.error);
|
|
2498
|
-
await
|
|
2499
|
-
data: JSON.stringify({ type: "error", errorText: String(part.error) })
|
|
2500
|
-
});
|
|
3460
|
+
await writeSSE(JSON.stringify({ type: "error", errorText: String(part.error) }));
|
|
2501
3461
|
}
|
|
2502
3462
|
}
|
|
2503
3463
|
if (textStarted) {
|
|
2504
|
-
await
|
|
2505
|
-
data: JSON.stringify({ type: "text-end", id: textId })
|
|
2506
|
-
});
|
|
3464
|
+
await writeSSE(JSON.stringify({ type: "text-end", id: textId }));
|
|
2507
3465
|
}
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
3466
|
+
if (reasoningStarted) {
|
|
3467
|
+
await writeSSE(JSON.stringify({ type: "reasoning-end", id: reasoningId }));
|
|
3468
|
+
}
|
|
3469
|
+
if (!isAborted) {
|
|
3470
|
+
await result.saveResponseMessages();
|
|
3471
|
+
}
|
|
3472
|
+
if (isAborted) {
|
|
3473
|
+
await writeSSE(JSON.stringify({ type: "abort" }));
|
|
3474
|
+
} else {
|
|
3475
|
+
await writeSSE(JSON.stringify({ type: "finish" }));
|
|
3476
|
+
}
|
|
3477
|
+
activeStreamQueries.finish(streamId);
|
|
2513
3478
|
} catch (error) {
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
})
|
|
2519
|
-
|
|
2520
|
-
|
|
3479
|
+
if (error.name === "AbortError" || error.message?.includes("aborted")) {
|
|
3480
|
+
await writeSSE(JSON.stringify({ type: "abort" }));
|
|
3481
|
+
} else {
|
|
3482
|
+
console.error("Agent error:", error);
|
|
3483
|
+
await writeSSE(JSON.stringify({ type: "error", errorText: error.message }));
|
|
3484
|
+
activeStreamQueries.markError(streamId);
|
|
3485
|
+
}
|
|
3486
|
+
} finally {
|
|
3487
|
+
cleanupAbortController();
|
|
3488
|
+
await writeSSE("[DONE]");
|
|
3489
|
+
await safeClose();
|
|
3490
|
+
}
|
|
3491
|
+
})();
|
|
3492
|
+
return readable;
|
|
3493
|
+
};
|
|
3494
|
+
}
|
|
3495
|
+
agents.post(
|
|
3496
|
+
"/:id/run",
|
|
3497
|
+
zValidator2("json", runPromptSchema),
|
|
3498
|
+
async (c) => {
|
|
3499
|
+
const id = c.req.param("id");
|
|
3500
|
+
const { prompt } = c.req.valid("json");
|
|
3501
|
+
const session = sessionQueries.getById(id);
|
|
3502
|
+
if (!session) {
|
|
3503
|
+
return c.json({ error: "Session not found" }, 404);
|
|
3504
|
+
}
|
|
3505
|
+
const nextSequence = messageQueries.getNextSequence(id);
|
|
3506
|
+
await createCheckpoint(id, session.workingDirectory, nextSequence);
|
|
3507
|
+
messageQueries.create(id, { role: "user", content: prompt });
|
|
3508
|
+
const streamId = `stream_${id}_${nanoid4(10)}`;
|
|
3509
|
+
activeStreamQueries.create(id, streamId);
|
|
3510
|
+
const stream = await streamContext.resumableStream(
|
|
3511
|
+
streamId,
|
|
3512
|
+
createAgentStreamProducer(id, prompt, streamId)
|
|
3513
|
+
);
|
|
3514
|
+
if (!stream) {
|
|
3515
|
+
return c.json({ error: "Failed to create stream" }, 500);
|
|
3516
|
+
}
|
|
3517
|
+
const encodedStream = stream.pipeThrough(new TextEncoderStream());
|
|
3518
|
+
return new Response(encodedStream, {
|
|
3519
|
+
headers: {
|
|
3520
|
+
"Content-Type": "text/event-stream",
|
|
3521
|
+
"Cache-Control": "no-cache",
|
|
3522
|
+
"Connection": "keep-alive",
|
|
3523
|
+
"x-vercel-ai-ui-message-stream": "v1",
|
|
3524
|
+
"x-stream-id": streamId
|
|
2521
3525
|
}
|
|
2522
3526
|
});
|
|
2523
3527
|
}
|
|
2524
3528
|
);
|
|
3529
|
+
agents.get("/:id/watch", async (c) => {
|
|
3530
|
+
const sessionId = c.req.param("id");
|
|
3531
|
+
const resumeAt = c.req.query("resumeAt");
|
|
3532
|
+
const explicitStreamId = c.req.query("streamId");
|
|
3533
|
+
const session = sessionQueries.getById(sessionId);
|
|
3534
|
+
if (!session) {
|
|
3535
|
+
return c.json({ error: "Session not found" }, 404);
|
|
3536
|
+
}
|
|
3537
|
+
let streamId = explicitStreamId;
|
|
3538
|
+
if (!streamId) {
|
|
3539
|
+
const activeStream = activeStreamQueries.getBySessionId(sessionId);
|
|
3540
|
+
if (!activeStream) {
|
|
3541
|
+
return c.json({ error: "No active stream for this session", hint: "Start a new run with POST /agents/:id/run" }, 404);
|
|
3542
|
+
}
|
|
3543
|
+
streamId = activeStream.streamId;
|
|
3544
|
+
}
|
|
3545
|
+
const stream = await streamContext.resumeExistingStream(
|
|
3546
|
+
streamId,
|
|
3547
|
+
resumeAt ? parseInt(resumeAt, 10) : void 0
|
|
3548
|
+
);
|
|
3549
|
+
if (!stream) {
|
|
3550
|
+
return c.json({
|
|
3551
|
+
error: "Stream is no longer active",
|
|
3552
|
+
streamId,
|
|
3553
|
+
hint: "The stream may have finished. Check /agents/:id/approvals or start a new run."
|
|
3554
|
+
}, 422);
|
|
3555
|
+
}
|
|
3556
|
+
const encodedStream = stream.pipeThrough(new TextEncoderStream());
|
|
3557
|
+
return new Response(encodedStream, {
|
|
3558
|
+
headers: {
|
|
3559
|
+
"Content-Type": "text/event-stream",
|
|
3560
|
+
"Cache-Control": "no-cache",
|
|
3561
|
+
"Connection": "keep-alive",
|
|
3562
|
+
"x-vercel-ai-ui-message-stream": "v1",
|
|
3563
|
+
"x-stream-id": streamId
|
|
3564
|
+
}
|
|
3565
|
+
});
|
|
3566
|
+
});
|
|
3567
|
+
agents.get("/:id/stream", async (c) => {
|
|
3568
|
+
const sessionId = c.req.param("id");
|
|
3569
|
+
const session = sessionQueries.getById(sessionId);
|
|
3570
|
+
if (!session) {
|
|
3571
|
+
return c.json({ error: "Session not found" }, 404);
|
|
3572
|
+
}
|
|
3573
|
+
const activeStream = activeStreamQueries.getBySessionId(sessionId);
|
|
3574
|
+
return c.json({
|
|
3575
|
+
sessionId,
|
|
3576
|
+
hasActiveStream: !!activeStream,
|
|
3577
|
+
stream: activeStream ? {
|
|
3578
|
+
id: activeStream.id,
|
|
3579
|
+
streamId: activeStream.streamId,
|
|
3580
|
+
status: activeStream.status,
|
|
3581
|
+
createdAt: activeStream.createdAt.toISOString()
|
|
3582
|
+
} : null
|
|
3583
|
+
});
|
|
3584
|
+
});
|
|
2525
3585
|
agents.post(
|
|
2526
3586
|
"/:id/generate",
|
|
2527
3587
|
zValidator2("json", runPromptSchema),
|
|
@@ -2607,6 +3667,28 @@ agents.get("/:id/approvals", async (c) => {
|
|
|
2607
3667
|
count: pendingApprovals.length
|
|
2608
3668
|
});
|
|
2609
3669
|
});
|
|
3670
|
+
agents.post("/:id/abort", async (c) => {
|
|
3671
|
+
const sessionId = c.req.param("id");
|
|
3672
|
+
const session = sessionQueries.getById(sessionId);
|
|
3673
|
+
if (!session) {
|
|
3674
|
+
return c.json({ error: "Session not found" }, 404);
|
|
3675
|
+
}
|
|
3676
|
+
const activeStream = activeStreamQueries.getBySessionId(sessionId);
|
|
3677
|
+
if (!activeStream) {
|
|
3678
|
+
return c.json({ error: "No active stream for this session" }, 404);
|
|
3679
|
+
}
|
|
3680
|
+
const abortController = streamAbortControllers.get(activeStream.streamId);
|
|
3681
|
+
if (abortController) {
|
|
3682
|
+
abortController.abort();
|
|
3683
|
+
streamAbortControllers.delete(activeStream.streamId);
|
|
3684
|
+
return c.json({ success: true, streamId: activeStream.streamId, aborted: true });
|
|
3685
|
+
}
|
|
3686
|
+
return c.json({
|
|
3687
|
+
success: false,
|
|
3688
|
+
streamId: activeStream.streamId,
|
|
3689
|
+
message: "Stream may have already finished or was not found"
|
|
3690
|
+
});
|
|
3691
|
+
});
|
|
2610
3692
|
agents.post(
|
|
2611
3693
|
"/quick",
|
|
2612
3694
|
zValidator2("json", quickStartSchema),
|
|
@@ -2620,14 +3702,41 @@ agents.post(
|
|
|
2620
3702
|
sessionConfig: body.toolApprovals ? { toolApprovals: body.toolApprovals } : void 0
|
|
2621
3703
|
});
|
|
2622
3704
|
const session = agent.getSession();
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
3705
|
+
const streamId = `stream_${session.id}_${nanoid4(10)}`;
|
|
3706
|
+
await createCheckpoint(session.id, session.workingDirectory, 0);
|
|
3707
|
+
activeStreamQueries.create(session.id, streamId);
|
|
3708
|
+
const createQuickStreamProducer = () => {
|
|
3709
|
+
const { readable, writable } = new TransformStream();
|
|
3710
|
+
const writer = writable.getWriter();
|
|
3711
|
+
let writerClosed = false;
|
|
3712
|
+
const abortController = new AbortController();
|
|
3713
|
+
streamAbortControllers.set(streamId, abortController);
|
|
3714
|
+
const writeSSE = async (data) => {
|
|
3715
|
+
if (writerClosed) return;
|
|
3716
|
+
try {
|
|
3717
|
+
await writer.write(`data: ${data}
|
|
3718
|
+
|
|
3719
|
+
`);
|
|
3720
|
+
} catch (err) {
|
|
3721
|
+
writerClosed = true;
|
|
3722
|
+
}
|
|
3723
|
+
};
|
|
3724
|
+
const safeClose = async () => {
|
|
3725
|
+
if (writerClosed) return;
|
|
3726
|
+
try {
|
|
3727
|
+
writerClosed = true;
|
|
3728
|
+
await writer.close();
|
|
3729
|
+
} catch {
|
|
3730
|
+
}
|
|
3731
|
+
};
|
|
3732
|
+
const cleanupAbortController = () => {
|
|
3733
|
+
streamAbortControllers.delete(streamId);
|
|
3734
|
+
};
|
|
3735
|
+
(async () => {
|
|
3736
|
+
let isAborted = false;
|
|
3737
|
+
try {
|
|
3738
|
+
await writeSSE(JSON.stringify({ type: "data-stream-id", streamId }));
|
|
3739
|
+
await writeSSE(JSON.stringify({
|
|
2631
3740
|
type: "data-session",
|
|
2632
3741
|
data: {
|
|
2633
3742
|
id: session.id,
|
|
@@ -2635,63 +3744,134 @@ agents.post(
|
|
|
2635
3744
|
workingDirectory: session.workingDirectory,
|
|
2636
3745
|
model: session.model
|
|
2637
3746
|
}
|
|
2638
|
-
})
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
});
|
|
2656
|
-
textStarted
|
|
2657
|
-
|
|
3747
|
+
}));
|
|
3748
|
+
const messageId = `msg_${Date.now()}`;
|
|
3749
|
+
await writeSSE(JSON.stringify({ type: "start", messageId }));
|
|
3750
|
+
let textId = `text_${Date.now()}`;
|
|
3751
|
+
let textStarted = false;
|
|
3752
|
+
const result = await agent.stream({
|
|
3753
|
+
prompt: body.prompt,
|
|
3754
|
+
abortSignal: abortController.signal,
|
|
3755
|
+
// Use our managed abort controller, NOT client signal
|
|
3756
|
+
onToolProgress: async (progress) => {
|
|
3757
|
+
await writeSSE(JSON.stringify({
|
|
3758
|
+
type: "tool-progress",
|
|
3759
|
+
toolName: progress.toolName,
|
|
3760
|
+
data: progress.data
|
|
3761
|
+
}));
|
|
3762
|
+
},
|
|
3763
|
+
onStepFinish: async () => {
|
|
3764
|
+
await writeSSE(JSON.stringify({ type: "finish-step" }));
|
|
3765
|
+
if (textStarted) {
|
|
3766
|
+
await writeSSE(JSON.stringify({ type: "text-end", id: textId }));
|
|
3767
|
+
textStarted = false;
|
|
3768
|
+
textId = `text_${Date.now()}`;
|
|
3769
|
+
}
|
|
3770
|
+
},
|
|
3771
|
+
onAbort: async ({ steps }) => {
|
|
3772
|
+
isAborted = true;
|
|
3773
|
+
console.log(`Stream aborted after ${steps.length} steps`);
|
|
2658
3774
|
}
|
|
2659
|
-
}
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
if (
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
3775
|
+
});
|
|
3776
|
+
let reasoningId = `reasoning_${Date.now()}`;
|
|
3777
|
+
let reasoningStarted = false;
|
|
3778
|
+
for await (const part of result.stream.fullStream) {
|
|
3779
|
+
if (part.type === "text-delta") {
|
|
3780
|
+
if (!textStarted) {
|
|
3781
|
+
await writeSSE(JSON.stringify({ type: "text-start", id: textId }));
|
|
3782
|
+
textStarted = true;
|
|
3783
|
+
}
|
|
3784
|
+
await writeSSE(JSON.stringify({ type: "text-delta", id: textId, delta: part.text }));
|
|
3785
|
+
} else if (part.type === "reasoning-start") {
|
|
3786
|
+
await writeSSE(JSON.stringify({ type: "reasoning-start", id: reasoningId }));
|
|
3787
|
+
reasoningStarted = true;
|
|
3788
|
+
} else if (part.type === "reasoning-delta") {
|
|
3789
|
+
await writeSSE(JSON.stringify({ type: "reasoning-delta", id: reasoningId, delta: part.text }));
|
|
3790
|
+
} else if (part.type === "reasoning-end") {
|
|
3791
|
+
if (reasoningStarted) {
|
|
3792
|
+
await writeSSE(JSON.stringify({ type: "reasoning-end", id: reasoningId }));
|
|
3793
|
+
reasoningStarted = false;
|
|
3794
|
+
reasoningId = `reasoning_${Date.now()}`;
|
|
3795
|
+
}
|
|
3796
|
+
} else if (part.type === "tool-call-streaming-start") {
|
|
3797
|
+
const p = part;
|
|
3798
|
+
await writeSSE(JSON.stringify({
|
|
3799
|
+
type: "tool-input-start",
|
|
3800
|
+
toolCallId: p.toolCallId,
|
|
3801
|
+
toolName: p.toolName
|
|
3802
|
+
}));
|
|
3803
|
+
} else if (part.type === "tool-call-delta") {
|
|
3804
|
+
const p = part;
|
|
3805
|
+
await writeSSE(JSON.stringify({
|
|
3806
|
+
type: "tool-input-delta",
|
|
3807
|
+
toolCallId: p.toolCallId,
|
|
3808
|
+
argsTextDelta: p.argsTextDelta
|
|
3809
|
+
}));
|
|
3810
|
+
} else if (part.type === "tool-call") {
|
|
3811
|
+
await writeSSE(JSON.stringify({
|
|
3812
|
+
type: "tool-input-available",
|
|
3813
|
+
toolCallId: part.toolCallId,
|
|
3814
|
+
toolName: part.toolName,
|
|
3815
|
+
input: part.input
|
|
3816
|
+
}));
|
|
3817
|
+
} else if (part.type === "tool-result") {
|
|
3818
|
+
await writeSSE(JSON.stringify({
|
|
3819
|
+
type: "tool-output-available",
|
|
3820
|
+
toolCallId: part.toolCallId,
|
|
3821
|
+
output: part.output
|
|
3822
|
+
}));
|
|
3823
|
+
} else if (part.type === "error") {
|
|
3824
|
+
console.error("Stream error:", part.error);
|
|
3825
|
+
await writeSSE(JSON.stringify({ type: "error", errorText: String(part.error) }));
|
|
2668
3826
|
}
|
|
2669
|
-
await stream.writeSSE({
|
|
2670
|
-
data: JSON.stringify({ type: "text-delta", id: textId, delta: part.text })
|
|
2671
|
-
});
|
|
2672
|
-
} else if (part.type === "error") {
|
|
2673
|
-
console.error("Stream error:", part.error);
|
|
2674
|
-
await stream.writeSSE({
|
|
2675
|
-
data: JSON.stringify({ type: "error", errorText: String(part.error) })
|
|
2676
|
-
});
|
|
2677
3827
|
}
|
|
3828
|
+
if (textStarted) {
|
|
3829
|
+
await writeSSE(JSON.stringify({ type: "text-end", id: textId }));
|
|
3830
|
+
}
|
|
3831
|
+
if (reasoningStarted) {
|
|
3832
|
+
await writeSSE(JSON.stringify({ type: "reasoning-end", id: reasoningId }));
|
|
3833
|
+
}
|
|
3834
|
+
if (!isAborted) {
|
|
3835
|
+
await result.saveResponseMessages();
|
|
3836
|
+
}
|
|
3837
|
+
if (isAborted) {
|
|
3838
|
+
await writeSSE(JSON.stringify({ type: "abort" }));
|
|
3839
|
+
} else {
|
|
3840
|
+
await writeSSE(JSON.stringify({ type: "finish" }));
|
|
3841
|
+
}
|
|
3842
|
+
activeStreamQueries.finish(streamId);
|
|
3843
|
+
} catch (error) {
|
|
3844
|
+
if (error.name === "AbortError" || error.message?.includes("aborted")) {
|
|
3845
|
+
await writeSSE(JSON.stringify({ type: "abort" }));
|
|
3846
|
+
} else {
|
|
3847
|
+
console.error("Agent error:", error);
|
|
3848
|
+
await writeSSE(JSON.stringify({ type: "error", errorText: error.message }));
|
|
3849
|
+
activeStreamQueries.markError(streamId);
|
|
3850
|
+
}
|
|
3851
|
+
} finally {
|
|
3852
|
+
cleanupAbortController();
|
|
3853
|
+
await writeSSE("[DONE]");
|
|
3854
|
+
await safeClose();
|
|
2678
3855
|
}
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
3856
|
+
})();
|
|
3857
|
+
return readable;
|
|
3858
|
+
};
|
|
3859
|
+
const stream = await streamContext.resumableStream(
|
|
3860
|
+
streamId,
|
|
3861
|
+
createQuickStreamProducer
|
|
3862
|
+
);
|
|
3863
|
+
if (!stream) {
|
|
3864
|
+
return c.json({ error: "Failed to create stream" }, 500);
|
|
3865
|
+
}
|
|
3866
|
+
const encodedStream = stream.pipeThrough(new TextEncoderStream());
|
|
3867
|
+
return new Response(encodedStream, {
|
|
3868
|
+
headers: {
|
|
3869
|
+
"Content-Type": "text/event-stream",
|
|
3870
|
+
"Cache-Control": "no-cache",
|
|
3871
|
+
"Connection": "keep-alive",
|
|
3872
|
+
"x-vercel-ai-ui-message-stream": "v1",
|
|
3873
|
+
"x-stream-id": streamId,
|
|
3874
|
+
"x-session-id": session.id
|
|
2695
3875
|
}
|
|
2696
3876
|
});
|
|
2697
3877
|
}
|
|
@@ -2699,16 +3879,23 @@ agents.post(
|
|
|
2699
3879
|
|
|
2700
3880
|
// src/server/routes/health.ts
|
|
2701
3881
|
import { Hono as Hono3 } from "hono";
|
|
3882
|
+
import { zValidator as zValidator3 } from "@hono/zod-validator";
|
|
3883
|
+
import { z as z10 } from "zod";
|
|
2702
3884
|
var health = new Hono3();
|
|
2703
3885
|
health.get("/", async (c) => {
|
|
2704
3886
|
const config = getConfig();
|
|
3887
|
+
const apiKeyStatus = getApiKeyStatus();
|
|
3888
|
+
const gatewayKey = apiKeyStatus.find((s) => s.provider === "ai-gateway");
|
|
3889
|
+
const hasApiKey = gatewayKey?.configured ?? false;
|
|
2705
3890
|
return c.json({
|
|
2706
3891
|
status: "ok",
|
|
2707
3892
|
version: "0.1.0",
|
|
2708
3893
|
uptime: process.uptime(),
|
|
3894
|
+
apiKeyConfigured: hasApiKey,
|
|
2709
3895
|
config: {
|
|
2710
3896
|
workingDirectory: config.resolvedWorkingDirectory,
|
|
2711
3897
|
defaultModel: config.defaultModel,
|
|
3898
|
+
defaultToolApprovals: config.toolApprovals || {},
|
|
2712
3899
|
port: config.server.port
|
|
2713
3900
|
},
|
|
2714
3901
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -2732,10 +3919,54 @@ health.get("/ready", async (c) => {
|
|
|
2732
3919
|
);
|
|
2733
3920
|
}
|
|
2734
3921
|
});
|
|
3922
|
+
health.get("/api-keys", async (c) => {
|
|
3923
|
+
const status = getApiKeyStatus();
|
|
3924
|
+
return c.json({
|
|
3925
|
+
providers: status,
|
|
3926
|
+
supportedProviders: SUPPORTED_PROVIDERS
|
|
3927
|
+
});
|
|
3928
|
+
});
|
|
3929
|
+
var setApiKeySchema = z10.object({
|
|
3930
|
+
provider: z10.string(),
|
|
3931
|
+
apiKey: z10.string().min(1)
|
|
3932
|
+
});
|
|
3933
|
+
health.post(
|
|
3934
|
+
"/api-keys",
|
|
3935
|
+
zValidator3("json", setApiKeySchema),
|
|
3936
|
+
async (c) => {
|
|
3937
|
+
const { provider, apiKey } = c.req.valid("json");
|
|
3938
|
+
try {
|
|
3939
|
+
setApiKey(provider, apiKey);
|
|
3940
|
+
const status = getApiKeyStatus();
|
|
3941
|
+
const providerStatus = status.find((s) => s.provider === provider.toLowerCase());
|
|
3942
|
+
return c.json({
|
|
3943
|
+
success: true,
|
|
3944
|
+
provider: provider.toLowerCase(),
|
|
3945
|
+
maskedKey: providerStatus?.maskedKey,
|
|
3946
|
+
message: `API key for ${provider} saved successfully`
|
|
3947
|
+
});
|
|
3948
|
+
} catch (error) {
|
|
3949
|
+
return c.json({ error: error.message }, 400);
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
);
|
|
3953
|
+
health.delete("/api-keys/:provider", async (c) => {
|
|
3954
|
+
const provider = c.req.param("provider");
|
|
3955
|
+
try {
|
|
3956
|
+
removeApiKey(provider);
|
|
3957
|
+
return c.json({
|
|
3958
|
+
success: true,
|
|
3959
|
+
provider: provider.toLowerCase(),
|
|
3960
|
+
message: `API key for ${provider} removed`
|
|
3961
|
+
});
|
|
3962
|
+
} catch (error) {
|
|
3963
|
+
return c.json({ error: error.message }, 400);
|
|
3964
|
+
}
|
|
3965
|
+
});
|
|
2735
3966
|
|
|
2736
3967
|
// src/server/routes/terminals.ts
|
|
2737
3968
|
import { Hono as Hono4 } from "hono";
|
|
2738
|
-
import { zValidator as
|
|
3969
|
+
import { zValidator as zValidator4 } from "@hono/zod-validator";
|
|
2739
3970
|
import { z as z11 } from "zod";
|
|
2740
3971
|
var terminals2 = new Hono4();
|
|
2741
3972
|
var spawnSchema = z11.object({
|
|
@@ -2745,7 +3976,7 @@ var spawnSchema = z11.object({
|
|
|
2745
3976
|
});
|
|
2746
3977
|
terminals2.post(
|
|
2747
3978
|
"/:sessionId/terminals",
|
|
2748
|
-
|
|
3979
|
+
zValidator4("json", spawnSchema),
|
|
2749
3980
|
async (c) => {
|
|
2750
3981
|
const sessionId = c.req.param("sessionId");
|
|
2751
3982
|
const body = c.req.valid("json");
|
|
@@ -2753,14 +3984,21 @@ terminals2.post(
|
|
|
2753
3984
|
if (!session) {
|
|
2754
3985
|
return c.json({ error: "Session not found" }, 404);
|
|
2755
3986
|
}
|
|
2756
|
-
const
|
|
2757
|
-
|
|
2758
|
-
|
|
3987
|
+
const hasTmux = await isTmuxAvailable();
|
|
3988
|
+
if (!hasTmux) {
|
|
3989
|
+
return c.json({ error: "tmux is not installed. Background terminals require tmux." }, 400);
|
|
3990
|
+
}
|
|
3991
|
+
const workingDirectory = body.cwd || session.workingDirectory;
|
|
3992
|
+
const result = await runBackground(body.command, workingDirectory, { sessionId });
|
|
3993
|
+
return c.json({
|
|
3994
|
+
id: result.id,
|
|
3995
|
+
name: body.name || null,
|
|
2759
3996
|
command: body.command,
|
|
2760
|
-
cwd:
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
3997
|
+
cwd: workingDirectory,
|
|
3998
|
+
status: result.status,
|
|
3999
|
+
pid: null
|
|
4000
|
+
// tmux doesn't expose PID directly
|
|
4001
|
+
}, 201);
|
|
2764
4002
|
}
|
|
2765
4003
|
);
|
|
2766
4004
|
terminals2.get("/:sessionId/terminals", async (c) => {
|
|
@@ -2769,8 +4007,20 @@ terminals2.get("/:sessionId/terminals", async (c) => {
|
|
|
2769
4007
|
if (!session) {
|
|
2770
4008
|
return c.json({ error: "Session not found" }, 404);
|
|
2771
4009
|
}
|
|
2772
|
-
const
|
|
2773
|
-
const terminalList =
|
|
4010
|
+
const sessionTerminals = await listSessionTerminals(sessionId, session.workingDirectory);
|
|
4011
|
+
const terminalList = await Promise.all(
|
|
4012
|
+
sessionTerminals.map(async (meta) => {
|
|
4013
|
+
const running = await isRunning(meta.id);
|
|
4014
|
+
return {
|
|
4015
|
+
id: meta.id,
|
|
4016
|
+
name: null,
|
|
4017
|
+
command: meta.command,
|
|
4018
|
+
cwd: meta.cwd,
|
|
4019
|
+
status: running ? "running" : "stopped",
|
|
4020
|
+
createdAt: meta.createdAt
|
|
4021
|
+
};
|
|
4022
|
+
})
|
|
4023
|
+
);
|
|
2774
4024
|
return c.json({
|
|
2775
4025
|
sessionId,
|
|
2776
4026
|
terminals: terminalList,
|
|
@@ -2781,31 +4031,47 @@ terminals2.get("/:sessionId/terminals", async (c) => {
|
|
|
2781
4031
|
terminals2.get("/:sessionId/terminals/:terminalId", async (c) => {
|
|
2782
4032
|
const sessionId = c.req.param("sessionId");
|
|
2783
4033
|
const terminalId = c.req.param("terminalId");
|
|
2784
|
-
const
|
|
2785
|
-
|
|
2786
|
-
|
|
4034
|
+
const session = sessionQueries.getById(sessionId);
|
|
4035
|
+
if (!session) {
|
|
4036
|
+
return c.json({ error: "Session not found" }, 404);
|
|
4037
|
+
}
|
|
4038
|
+
const meta = await getMeta(terminalId, session.workingDirectory, sessionId);
|
|
4039
|
+
if (!meta) {
|
|
2787
4040
|
return c.json({ error: "Terminal not found" }, 404);
|
|
2788
4041
|
}
|
|
2789
|
-
|
|
4042
|
+
const running = await isRunning(terminalId);
|
|
4043
|
+
return c.json({
|
|
4044
|
+
id: terminalId,
|
|
4045
|
+
command: meta.command,
|
|
4046
|
+
cwd: meta.cwd,
|
|
4047
|
+
status: running ? "running" : "stopped",
|
|
4048
|
+
createdAt: meta.createdAt,
|
|
4049
|
+
exitCode: running ? null : 0
|
|
4050
|
+
// We don't track exit codes in tmux mode
|
|
4051
|
+
});
|
|
2790
4052
|
});
|
|
2791
4053
|
var logsQuerySchema = z11.object({
|
|
2792
4054
|
tail: z11.string().optional().transform((v) => v ? parseInt(v, 10) : void 0)
|
|
2793
4055
|
});
|
|
2794
4056
|
terminals2.get(
|
|
2795
4057
|
"/:sessionId/terminals/:terminalId/logs",
|
|
2796
|
-
|
|
4058
|
+
zValidator4("query", logsQuerySchema),
|
|
2797
4059
|
async (c) => {
|
|
4060
|
+
const sessionId = c.req.param("sessionId");
|
|
2798
4061
|
const terminalId = c.req.param("terminalId");
|
|
2799
4062
|
const query = c.req.valid("query");
|
|
2800
|
-
const
|
|
2801
|
-
|
|
2802
|
-
|
|
4063
|
+
const session = sessionQueries.getById(sessionId);
|
|
4064
|
+
if (!session) {
|
|
4065
|
+
return c.json({ error: "Session not found" }, 404);
|
|
4066
|
+
}
|
|
4067
|
+
const result = await getLogs(terminalId, session.workingDirectory, { tail: query.tail, sessionId });
|
|
4068
|
+
if (result.status === "unknown") {
|
|
2803
4069
|
return c.json({ error: "Terminal not found" }, 404);
|
|
2804
4070
|
}
|
|
2805
4071
|
return c.json({
|
|
2806
4072
|
terminalId,
|
|
2807
|
-
logs: result.
|
|
2808
|
-
lineCount: result.
|
|
4073
|
+
logs: result.output,
|
|
4074
|
+
lineCount: result.output.split("\n").length
|
|
2809
4075
|
});
|
|
2810
4076
|
}
|
|
2811
4077
|
);
|
|
@@ -2814,16 +4080,14 @@ var killSchema = z11.object({
|
|
|
2814
4080
|
});
|
|
2815
4081
|
terminals2.post(
|
|
2816
4082
|
"/:sessionId/terminals/:terminalId/kill",
|
|
2817
|
-
|
|
4083
|
+
zValidator4("json", killSchema.optional()),
|
|
2818
4084
|
async (c) => {
|
|
2819
4085
|
const terminalId = c.req.param("terminalId");
|
|
2820
|
-
const
|
|
2821
|
-
const manager = getTerminalManager();
|
|
2822
|
-
const success = manager.kill(terminalId, body.signal);
|
|
4086
|
+
const success = await killTerminal(terminalId);
|
|
2823
4087
|
if (!success) {
|
|
2824
|
-
return c.json({ error: "Failed to kill terminal" }, 400);
|
|
4088
|
+
return c.json({ error: "Failed to kill terminal (may already be stopped)" }, 400);
|
|
2825
4089
|
}
|
|
2826
|
-
return c.json({ success: true, message:
|
|
4090
|
+
return c.json({ success: true, message: "Terminal killed" });
|
|
2827
4091
|
}
|
|
2828
4092
|
);
|
|
2829
4093
|
var writeSchema = z11.object({
|
|
@@ -2831,97 +4095,164 @@ var writeSchema = z11.object({
|
|
|
2831
4095
|
});
|
|
2832
4096
|
terminals2.post(
|
|
2833
4097
|
"/:sessionId/terminals/:terminalId/write",
|
|
2834
|
-
|
|
4098
|
+
zValidator4("json", writeSchema),
|
|
2835
4099
|
async (c) => {
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
if (!success) {
|
|
2841
|
-
return c.json({ error: "Failed to write to terminal" }, 400);
|
|
2842
|
-
}
|
|
2843
|
-
return c.json({ success: true });
|
|
4100
|
+
return c.json({
|
|
4101
|
+
error: "stdin writing not supported in tmux mode. Use tmux send-keys directly if needed.",
|
|
4102
|
+
hint: 'tmux send-keys -t spark_{terminalId} "your input"'
|
|
4103
|
+
}, 501);
|
|
2844
4104
|
}
|
|
2845
4105
|
);
|
|
2846
4106
|
terminals2.post("/:sessionId/terminals/kill-all", async (c) => {
|
|
2847
4107
|
const sessionId = c.req.param("sessionId");
|
|
2848
|
-
const
|
|
2849
|
-
|
|
4108
|
+
const session = sessionQueries.getById(sessionId);
|
|
4109
|
+
if (!session) {
|
|
4110
|
+
return c.json({ error: "Session not found" }, 404);
|
|
4111
|
+
}
|
|
4112
|
+
const terminalIds = await listSessions();
|
|
4113
|
+
let killed = 0;
|
|
4114
|
+
for (const id of terminalIds) {
|
|
4115
|
+
const meta = await getMeta(id, session.workingDirectory);
|
|
4116
|
+
if (meta && meta.sessionId === sessionId) {
|
|
4117
|
+
const success = await killTerminal(id);
|
|
4118
|
+
if (success) killed++;
|
|
4119
|
+
}
|
|
4120
|
+
}
|
|
2850
4121
|
return c.json({ success: true, killed });
|
|
2851
4122
|
});
|
|
4123
|
+
terminals2.get("/stream/:terminalId", async (c) => {
|
|
4124
|
+
const terminalId = c.req.param("terminalId");
|
|
4125
|
+
const sessions3 = sessionQueries.list();
|
|
4126
|
+
let terminalMeta = null;
|
|
4127
|
+
let workingDirectory = process.cwd();
|
|
4128
|
+
let foundSessionId;
|
|
4129
|
+
for (const session of sessions3) {
|
|
4130
|
+
terminalMeta = await getMeta(terminalId, session.workingDirectory, session.id);
|
|
4131
|
+
if (terminalMeta) {
|
|
4132
|
+
workingDirectory = session.workingDirectory;
|
|
4133
|
+
foundSessionId = session.id;
|
|
4134
|
+
break;
|
|
4135
|
+
}
|
|
4136
|
+
}
|
|
4137
|
+
if (!terminalMeta) {
|
|
4138
|
+
for (const session of sessions3) {
|
|
4139
|
+
terminalMeta = await getMeta(terminalId, session.workingDirectory);
|
|
4140
|
+
if (terminalMeta) {
|
|
4141
|
+
workingDirectory = session.workingDirectory;
|
|
4142
|
+
foundSessionId = terminalMeta.sessionId;
|
|
4143
|
+
break;
|
|
4144
|
+
}
|
|
4145
|
+
}
|
|
4146
|
+
}
|
|
4147
|
+
const isActive = await isRunning(terminalId);
|
|
4148
|
+
if (!terminalMeta && !isActive) {
|
|
4149
|
+
return c.json({ error: "Terminal not found" }, 404);
|
|
4150
|
+
}
|
|
4151
|
+
return new Response(
|
|
4152
|
+
new ReadableStream({
|
|
4153
|
+
async start(controller) {
|
|
4154
|
+
const encoder = new TextEncoder();
|
|
4155
|
+
let lastOutput = "";
|
|
4156
|
+
let isRunning2 = true;
|
|
4157
|
+
let pollCount = 0;
|
|
4158
|
+
const maxPolls = 600;
|
|
4159
|
+
controller.enqueue(
|
|
4160
|
+
encoder.encode(`event: status
|
|
4161
|
+
data: ${JSON.stringify({ terminalId, status: "connected" })}
|
|
4162
|
+
|
|
4163
|
+
`)
|
|
4164
|
+
);
|
|
4165
|
+
while (isRunning2 && pollCount < maxPolls) {
|
|
4166
|
+
try {
|
|
4167
|
+
const result = await getLogs(terminalId, workingDirectory, { sessionId: foundSessionId });
|
|
4168
|
+
if (result.output !== lastOutput) {
|
|
4169
|
+
const newContent = result.output.slice(lastOutput.length);
|
|
4170
|
+
if (newContent) {
|
|
4171
|
+
controller.enqueue(
|
|
4172
|
+
encoder.encode(`event: stdout
|
|
4173
|
+
data: ${JSON.stringify({ data: newContent })}
|
|
4174
|
+
|
|
4175
|
+
`)
|
|
4176
|
+
);
|
|
4177
|
+
}
|
|
4178
|
+
lastOutput = result.output;
|
|
4179
|
+
}
|
|
4180
|
+
isRunning2 = result.status === "running";
|
|
4181
|
+
if (!isRunning2) {
|
|
4182
|
+
controller.enqueue(
|
|
4183
|
+
encoder.encode(`event: exit
|
|
4184
|
+
data: ${JSON.stringify({ status: "stopped" })}
|
|
4185
|
+
|
|
4186
|
+
`)
|
|
4187
|
+
);
|
|
4188
|
+
break;
|
|
4189
|
+
}
|
|
4190
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
4191
|
+
pollCount++;
|
|
4192
|
+
} catch {
|
|
4193
|
+
break;
|
|
4194
|
+
}
|
|
4195
|
+
}
|
|
4196
|
+
controller.close();
|
|
4197
|
+
}
|
|
4198
|
+
}),
|
|
4199
|
+
{
|
|
4200
|
+
headers: {
|
|
4201
|
+
"Content-Type": "text/event-stream",
|
|
4202
|
+
"Cache-Control": "no-cache",
|
|
4203
|
+
"Connection": "keep-alive"
|
|
4204
|
+
}
|
|
4205
|
+
}
|
|
4206
|
+
);
|
|
4207
|
+
});
|
|
2852
4208
|
terminals2.get("/:sessionId/terminals/:terminalId/stream", async (c) => {
|
|
4209
|
+
const sessionId = c.req.param("sessionId");
|
|
2853
4210
|
const terminalId = c.req.param("terminalId");
|
|
2854
|
-
const
|
|
2855
|
-
|
|
2856
|
-
|
|
4211
|
+
const session = sessionQueries.getById(sessionId);
|
|
4212
|
+
if (!session) {
|
|
4213
|
+
return c.json({ error: "Session not found" }, 404);
|
|
4214
|
+
}
|
|
4215
|
+
const meta = await getMeta(terminalId, session.workingDirectory, sessionId);
|
|
4216
|
+
if (!meta) {
|
|
2857
4217
|
return c.json({ error: "Terminal not found" }, 404);
|
|
2858
4218
|
}
|
|
2859
|
-
c.header("Content-Type", "text/event-stream");
|
|
2860
|
-
c.header("Cache-Control", "no-cache");
|
|
2861
|
-
c.header("Connection", "keep-alive");
|
|
2862
4219
|
return new Response(
|
|
2863
4220
|
new ReadableStream({
|
|
2864
|
-
start(controller) {
|
|
4221
|
+
async start(controller) {
|
|
2865
4222
|
const encoder = new TextEncoder();
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
controller.enqueue(
|
|
2878
|
-
encoder.encode(`event: stdout
|
|
2879
|
-
data: ${JSON.stringify({ data })}
|
|
2880
|
-
|
|
2881
|
-
`)
|
|
2882
|
-
);
|
|
2883
|
-
}
|
|
2884
|
-
};
|
|
2885
|
-
const onStderr = ({ terminalId: tid, data }) => {
|
|
2886
|
-
if (tid === terminalId) {
|
|
2887
|
-
controller.enqueue(
|
|
2888
|
-
encoder.encode(`event: stderr
|
|
2889
|
-
data: ${JSON.stringify({ data })}
|
|
4223
|
+
let lastOutput = "";
|
|
4224
|
+
let isRunning2 = true;
|
|
4225
|
+
while (isRunning2) {
|
|
4226
|
+
try {
|
|
4227
|
+
const result = await getLogs(terminalId, session.workingDirectory, { sessionId });
|
|
4228
|
+
if (result.output !== lastOutput) {
|
|
4229
|
+
const newContent = result.output.slice(lastOutput.length);
|
|
4230
|
+
if (newContent) {
|
|
4231
|
+
controller.enqueue(
|
|
4232
|
+
encoder.encode(`event: stdout
|
|
4233
|
+
data: ${JSON.stringify({ data: newContent })}
|
|
2890
4234
|
|
|
2891
4235
|
`)
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
4236
|
+
);
|
|
4237
|
+
}
|
|
4238
|
+
lastOutput = result.output;
|
|
4239
|
+
}
|
|
4240
|
+
isRunning2 = result.status === "running";
|
|
4241
|
+
if (!isRunning2) {
|
|
4242
|
+
controller.enqueue(
|
|
4243
|
+
encoder.encode(`event: exit
|
|
4244
|
+
data: ${JSON.stringify({ status: "stopped" })}
|
|
2900
4245
|
|
|
2901
4246
|
`)
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
4247
|
+
);
|
|
4248
|
+
break;
|
|
4249
|
+
}
|
|
4250
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
4251
|
+
} catch {
|
|
4252
|
+
break;
|
|
2905
4253
|
}
|
|
2906
|
-
};
|
|
2907
|
-
const cleanup = () => {
|
|
2908
|
-
manager.off("stdout", onStdout);
|
|
2909
|
-
manager.off("stderr", onStderr);
|
|
2910
|
-
manager.off("exit", onExit);
|
|
2911
|
-
};
|
|
2912
|
-
manager.on("stdout", onStdout);
|
|
2913
|
-
manager.on("stderr", onStderr);
|
|
2914
|
-
manager.on("exit", onExit);
|
|
2915
|
-
if (terminal.status !== "running") {
|
|
2916
|
-
controller.enqueue(
|
|
2917
|
-
encoder.encode(`event: exit
|
|
2918
|
-
data: ${JSON.stringify({ code: terminal.exitCode, status: terminal.status })}
|
|
2919
|
-
|
|
2920
|
-
`)
|
|
2921
|
-
);
|
|
2922
|
-
cleanup();
|
|
2923
|
-
controller.close();
|
|
2924
4254
|
}
|
|
4255
|
+
controller.close();
|
|
2925
4256
|
}
|
|
2926
4257
|
}),
|
|
2927
4258
|
{
|
|
@@ -2934,16 +4265,270 @@ data: ${JSON.stringify({ code: terminal.exitCode, status: terminal.status })}
|
|
|
2934
4265
|
);
|
|
2935
4266
|
});
|
|
2936
4267
|
|
|
4268
|
+
// src/utils/dependencies.ts
|
|
4269
|
+
import { exec as exec4 } from "child_process";
|
|
4270
|
+
import { promisify as promisify4 } from "util";
|
|
4271
|
+
import { platform as platform2 } from "os";
|
|
4272
|
+
var execAsync4 = promisify4(exec4);
|
|
4273
|
+
function getInstallInstructions() {
|
|
4274
|
+
const os2 = platform2();
|
|
4275
|
+
if (os2 === "darwin") {
|
|
4276
|
+
return `
|
|
4277
|
+
Install tmux on macOS:
|
|
4278
|
+
brew install tmux
|
|
4279
|
+
|
|
4280
|
+
If you don't have Homebrew, install it first:
|
|
4281
|
+
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
|
4282
|
+
`.trim();
|
|
4283
|
+
}
|
|
4284
|
+
if (os2 === "linux") {
|
|
4285
|
+
return `
|
|
4286
|
+
Install tmux on Linux:
|
|
4287
|
+
# Ubuntu/Debian
|
|
4288
|
+
sudo apt-get update && sudo apt-get install -y tmux
|
|
4289
|
+
|
|
4290
|
+
# Fedora/RHEL
|
|
4291
|
+
sudo dnf install -y tmux
|
|
4292
|
+
|
|
4293
|
+
# Arch Linux
|
|
4294
|
+
sudo pacman -S tmux
|
|
4295
|
+
`.trim();
|
|
4296
|
+
}
|
|
4297
|
+
return `
|
|
4298
|
+
Install tmux:
|
|
4299
|
+
Please install tmux for your operating system.
|
|
4300
|
+
Visit: https://github.com/tmux/tmux/wiki/Installing
|
|
4301
|
+
`.trim();
|
|
4302
|
+
}
|
|
4303
|
+
async function checkTmux() {
|
|
4304
|
+
try {
|
|
4305
|
+
const { stdout } = await execAsync4("tmux -V", { timeout: 5e3 });
|
|
4306
|
+
const version = stdout.trim();
|
|
4307
|
+
return {
|
|
4308
|
+
available: true,
|
|
4309
|
+
version
|
|
4310
|
+
};
|
|
4311
|
+
} catch (error) {
|
|
4312
|
+
return {
|
|
4313
|
+
available: false,
|
|
4314
|
+
error: "tmux is not installed or not in PATH",
|
|
4315
|
+
installInstructions: getInstallInstructions()
|
|
4316
|
+
};
|
|
4317
|
+
}
|
|
4318
|
+
}
|
|
4319
|
+
async function checkDependencies(options = {}) {
|
|
4320
|
+
const { quiet = false, exitOnFailure = true } = options;
|
|
4321
|
+
const tmuxCheck = await checkTmux();
|
|
4322
|
+
if (!tmuxCheck.available) {
|
|
4323
|
+
if (!quiet) {
|
|
4324
|
+
console.error("\n\u274C Missing required dependency: tmux");
|
|
4325
|
+
console.error("");
|
|
4326
|
+
console.error("SparkECoder requires tmux for terminal session management.");
|
|
4327
|
+
console.error("");
|
|
4328
|
+
if (tmuxCheck.installInstructions) {
|
|
4329
|
+
console.error(tmuxCheck.installInstructions);
|
|
4330
|
+
}
|
|
4331
|
+
console.error("");
|
|
4332
|
+
console.error("After installing tmux, run sparkecoder again.");
|
|
4333
|
+
console.error("");
|
|
4334
|
+
}
|
|
4335
|
+
if (exitOnFailure) {
|
|
4336
|
+
process.exit(1);
|
|
4337
|
+
}
|
|
4338
|
+
return false;
|
|
4339
|
+
}
|
|
4340
|
+
if (!quiet) {
|
|
4341
|
+
}
|
|
4342
|
+
return true;
|
|
4343
|
+
}
|
|
4344
|
+
async function tryAutoInstallTmux() {
|
|
4345
|
+
const os2 = platform2();
|
|
4346
|
+
try {
|
|
4347
|
+
if (os2 === "darwin") {
|
|
4348
|
+
try {
|
|
4349
|
+
await execAsync4("which brew", { timeout: 5e3 });
|
|
4350
|
+
} catch {
|
|
4351
|
+
return false;
|
|
4352
|
+
}
|
|
4353
|
+
console.log("\u{1F4E6} Installing tmux via Homebrew...");
|
|
4354
|
+
await execAsync4("brew install tmux", { timeout: 3e5 });
|
|
4355
|
+
console.log("\u2705 tmux installed successfully");
|
|
4356
|
+
return true;
|
|
4357
|
+
}
|
|
4358
|
+
if (os2 === "linux") {
|
|
4359
|
+
try {
|
|
4360
|
+
await execAsync4("which apt-get", { timeout: 5e3 });
|
|
4361
|
+
console.log("\u{1F4E6} Installing tmux via apt-get...");
|
|
4362
|
+
console.log(" (This may require sudo password)");
|
|
4363
|
+
await execAsync4("sudo apt-get update && sudo apt-get install -y tmux", {
|
|
4364
|
+
timeout: 3e5
|
|
4365
|
+
});
|
|
4366
|
+
console.log("\u2705 tmux installed successfully");
|
|
4367
|
+
return true;
|
|
4368
|
+
} catch {
|
|
4369
|
+
}
|
|
4370
|
+
try {
|
|
4371
|
+
await execAsync4("which dnf", { timeout: 5e3 });
|
|
4372
|
+
console.log("\u{1F4E6} Installing tmux via dnf...");
|
|
4373
|
+
await execAsync4("sudo dnf install -y tmux", { timeout: 3e5 });
|
|
4374
|
+
console.log("\u2705 tmux installed successfully");
|
|
4375
|
+
return true;
|
|
4376
|
+
} catch {
|
|
4377
|
+
}
|
|
4378
|
+
}
|
|
4379
|
+
return false;
|
|
4380
|
+
} catch (error) {
|
|
4381
|
+
console.error(`Failed to auto-install tmux: ${error.message}`);
|
|
4382
|
+
return false;
|
|
4383
|
+
}
|
|
4384
|
+
}
|
|
4385
|
+
async function ensureDependencies(options = {}) {
|
|
4386
|
+
const { autoInstall = false, quiet = false } = options;
|
|
4387
|
+
const tmuxCheck = await checkTmux();
|
|
4388
|
+
if (tmuxCheck.available) {
|
|
4389
|
+
return;
|
|
4390
|
+
}
|
|
4391
|
+
if (autoInstall) {
|
|
4392
|
+
const installed = await tryAutoInstallTmux();
|
|
4393
|
+
if (installed) {
|
|
4394
|
+
return;
|
|
4395
|
+
}
|
|
4396
|
+
}
|
|
4397
|
+
await checkDependencies({ quiet, exitOnFailure: true });
|
|
4398
|
+
}
|
|
4399
|
+
|
|
2937
4400
|
// src/server/index.ts
|
|
2938
4401
|
var serverInstance = null;
|
|
2939
|
-
|
|
4402
|
+
var webUIProcess = null;
|
|
4403
|
+
var DEFAULT_WEB_PORT = 6969;
|
|
4404
|
+
var WEB_PORT_SEQUENCE = [6969, 6970, 6971, 6972, 6973, 6974, 6975, 6976, 6977, 6978];
|
|
4405
|
+
function getWebDirectory() {
|
|
4406
|
+
try {
|
|
4407
|
+
const currentDir = dirname4(fileURLToPath(import.meta.url));
|
|
4408
|
+
const webDir = resolve6(currentDir, "..", "web");
|
|
4409
|
+
if (existsSync7(webDir) && existsSync7(join3(webDir, "package.json"))) {
|
|
4410
|
+
return webDir;
|
|
4411
|
+
}
|
|
4412
|
+
const altWebDir = resolve6(currentDir, "..", "..", "web");
|
|
4413
|
+
if (existsSync7(altWebDir) && existsSync7(join3(altWebDir, "package.json"))) {
|
|
4414
|
+
return altWebDir;
|
|
4415
|
+
}
|
|
4416
|
+
return null;
|
|
4417
|
+
} catch {
|
|
4418
|
+
return null;
|
|
4419
|
+
}
|
|
4420
|
+
}
|
|
4421
|
+
async function isSparkcoderWebRunning(port) {
|
|
4422
|
+
try {
|
|
4423
|
+
const response = await fetch(`http://localhost:${port}/api/health`, {
|
|
4424
|
+
signal: AbortSignal.timeout(1e3)
|
|
4425
|
+
});
|
|
4426
|
+
if (response.ok) {
|
|
4427
|
+
const data = await response.json();
|
|
4428
|
+
return data.name === "sparkecoder-web";
|
|
4429
|
+
}
|
|
4430
|
+
return false;
|
|
4431
|
+
} catch {
|
|
4432
|
+
return false;
|
|
4433
|
+
}
|
|
4434
|
+
}
|
|
4435
|
+
function isPortInUse(port) {
|
|
4436
|
+
return new Promise((resolve8) => {
|
|
4437
|
+
const server = createNetServer();
|
|
4438
|
+
server.once("error", (err) => {
|
|
4439
|
+
if (err.code === "EADDRINUSE") {
|
|
4440
|
+
resolve8(true);
|
|
4441
|
+
} else {
|
|
4442
|
+
resolve8(false);
|
|
4443
|
+
}
|
|
4444
|
+
});
|
|
4445
|
+
server.once("listening", () => {
|
|
4446
|
+
server.close();
|
|
4447
|
+
resolve8(false);
|
|
4448
|
+
});
|
|
4449
|
+
server.listen(port, "0.0.0.0");
|
|
4450
|
+
});
|
|
4451
|
+
}
|
|
4452
|
+
async function findWebPort(preferredPort) {
|
|
4453
|
+
if (await isSparkcoderWebRunning(preferredPort)) {
|
|
4454
|
+
return { port: preferredPort, alreadyRunning: true };
|
|
4455
|
+
}
|
|
4456
|
+
if (!await isPortInUse(preferredPort)) {
|
|
4457
|
+
return { port: preferredPort, alreadyRunning: false };
|
|
4458
|
+
}
|
|
4459
|
+
for (const port of WEB_PORT_SEQUENCE) {
|
|
4460
|
+
if (port === preferredPort) continue;
|
|
4461
|
+
if (await isSparkcoderWebRunning(port)) {
|
|
4462
|
+
return { port, alreadyRunning: true };
|
|
4463
|
+
}
|
|
4464
|
+
if (!await isPortInUse(port)) {
|
|
4465
|
+
return { port, alreadyRunning: false };
|
|
4466
|
+
}
|
|
4467
|
+
}
|
|
4468
|
+
return { port: preferredPort, alreadyRunning: false };
|
|
4469
|
+
}
|
|
4470
|
+
async function startWebUI(apiPort, webPort = DEFAULT_WEB_PORT, quiet = false) {
|
|
4471
|
+
const webDir = getWebDirectory();
|
|
4472
|
+
if (!webDir) {
|
|
4473
|
+
if (!quiet) console.log(" \u26A0 Web UI not found, skipping...");
|
|
4474
|
+
return { process: null, port: webPort };
|
|
4475
|
+
}
|
|
4476
|
+
const { port: actualPort, alreadyRunning } = await findWebPort(webPort);
|
|
4477
|
+
if (alreadyRunning) {
|
|
4478
|
+
if (!quiet) console.log(` \u2713 Web UI already running at http://localhost:${actualPort}`);
|
|
4479
|
+
return { process: null, port: actualPort };
|
|
4480
|
+
}
|
|
4481
|
+
const useNpm = existsSync7(join3(webDir, "package-lock.json"));
|
|
4482
|
+
const command = useNpm ? "npm" : "npx";
|
|
4483
|
+
const args = useNpm ? ["run", "dev", "--", "-p", String(actualPort)] : ["next", "dev", "-p", String(actualPort)];
|
|
4484
|
+
const child = spawn(command, args, {
|
|
4485
|
+
cwd: webDir,
|
|
4486
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
4487
|
+
env: {
|
|
4488
|
+
...process.env,
|
|
4489
|
+
NEXT_PUBLIC_API_URL: `http://127.0.0.1:${apiPort}`
|
|
4490
|
+
},
|
|
4491
|
+
detached: false
|
|
4492
|
+
});
|
|
4493
|
+
let started = false;
|
|
4494
|
+
child.stdout?.on("data", (data) => {
|
|
4495
|
+
const output = data.toString();
|
|
4496
|
+
if (!started && (output.includes("Ready") || output.includes("started") || output.includes("localhost"))) {
|
|
4497
|
+
started = true;
|
|
4498
|
+
if (!quiet) console.log(` \u2713 Web UI running at http://localhost:${actualPort}`);
|
|
4499
|
+
}
|
|
4500
|
+
});
|
|
4501
|
+
if (!quiet) {
|
|
4502
|
+
child.stderr?.on("data", (data) => {
|
|
4503
|
+
const output = data.toString();
|
|
4504
|
+
if (output.toLowerCase().includes("error")) {
|
|
4505
|
+
console.error(` Web UI error: ${output.trim()}`);
|
|
4506
|
+
}
|
|
4507
|
+
});
|
|
4508
|
+
}
|
|
4509
|
+
child.on("exit", () => {
|
|
4510
|
+
webUIProcess = null;
|
|
4511
|
+
});
|
|
4512
|
+
webUIProcess = child;
|
|
4513
|
+
return { process: child, port: actualPort };
|
|
4514
|
+
}
|
|
4515
|
+
function stopWebUI() {
|
|
4516
|
+
if (webUIProcess) {
|
|
4517
|
+
webUIProcess.kill("SIGTERM");
|
|
4518
|
+
webUIProcess = null;
|
|
4519
|
+
}
|
|
4520
|
+
}
|
|
4521
|
+
async function createApp(options = {}) {
|
|
2940
4522
|
const app = new Hono5();
|
|
2941
4523
|
app.use("*", cors());
|
|
2942
|
-
|
|
4524
|
+
if (!options.quiet) {
|
|
4525
|
+
app.use("*", logger());
|
|
4526
|
+
}
|
|
2943
4527
|
app.route("/health", health);
|
|
2944
4528
|
app.route("/sessions", sessions2);
|
|
2945
4529
|
app.route("/agents", agents);
|
|
2946
4530
|
app.route("/sessions", terminals2);
|
|
4531
|
+
app.route("/terminals", terminals2);
|
|
2947
4532
|
app.get("/openapi.json", async (c) => {
|
|
2948
4533
|
return c.json(generateOpenAPISpec());
|
|
2949
4534
|
});
|
|
@@ -2952,7 +4537,7 @@ async function createApp() {
|
|
|
2952
4537
|
<html lang="en">
|
|
2953
4538
|
<head>
|
|
2954
4539
|
<meta charset="UTF-8">
|
|
2955
|
-
<title>
|
|
4540
|
+
<title>SparkECoder API - Swagger UI</title>
|
|
2956
4541
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
|
2957
4542
|
</head>
|
|
2958
4543
|
<body>
|
|
@@ -2972,7 +4557,7 @@ async function createApp() {
|
|
|
2972
4557
|
});
|
|
2973
4558
|
app.get("/", (c) => {
|
|
2974
4559
|
return c.json({
|
|
2975
|
-
name: "
|
|
4560
|
+
name: "SparkECoder API",
|
|
2976
4561
|
version: "0.1.0",
|
|
2977
4562
|
description: "A powerful coding agent CLI with HTTP API",
|
|
2978
4563
|
docs: "/openapi.json",
|
|
@@ -2987,37 +4572,63 @@ async function createApp() {
|
|
|
2987
4572
|
return app;
|
|
2988
4573
|
}
|
|
2989
4574
|
async function startServer(options = {}) {
|
|
4575
|
+
const depsOk = await checkDependencies({ quiet: options.quiet, exitOnFailure: false });
|
|
4576
|
+
if (!depsOk) {
|
|
4577
|
+
throw new Error("Missing required dependency: tmux. See above for installation instructions.");
|
|
4578
|
+
}
|
|
2990
4579
|
const config = await loadConfig(options.configPath, options.workingDirectory);
|
|
4580
|
+
loadApiKeysIntoEnv();
|
|
2991
4581
|
if (options.workingDirectory) {
|
|
2992
4582
|
config.resolvedWorkingDirectory = options.workingDirectory;
|
|
2993
4583
|
}
|
|
2994
|
-
if (!
|
|
2995
|
-
|
|
2996
|
-
console.log(`\u{1F4C1} Created agent workspace: ${config.resolvedWorkingDirectory}`);
|
|
4584
|
+
if (!existsSync7(config.resolvedWorkingDirectory)) {
|
|
4585
|
+
mkdirSync2(config.resolvedWorkingDirectory, { recursive: true });
|
|
4586
|
+
if (!options.quiet) console.log(`\u{1F4C1} Created agent workspace: ${config.resolvedWorkingDirectory}`);
|
|
2997
4587
|
}
|
|
2998
4588
|
initDatabase(config.resolvedDatabasePath);
|
|
2999
4589
|
const port = options.port || config.server.port;
|
|
3000
4590
|
const host = options.host || config.server.host || "0.0.0.0";
|
|
3001
|
-
const app = await createApp();
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
4591
|
+
const app = await createApp({ quiet: options.quiet });
|
|
4592
|
+
if (!options.quiet) {
|
|
4593
|
+
console.log(`
|
|
4594
|
+
\u{1F680} SparkECoder API Server`);
|
|
4595
|
+
console.log(` \u2192 Running at http://${host}:${port}`);
|
|
4596
|
+
console.log(` \u2192 Working directory: ${config.resolvedWorkingDirectory}`);
|
|
4597
|
+
console.log(` \u2192 Default model: ${config.defaultModel}`);
|
|
4598
|
+
console.log(` \u2192 OpenAPI spec: http://${host}:${port}/openapi.json
|
|
3008
4599
|
`);
|
|
4600
|
+
}
|
|
3009
4601
|
serverInstance = serve({
|
|
3010
4602
|
fetch: app.fetch,
|
|
3011
4603
|
port,
|
|
3012
4604
|
hostname: host
|
|
3013
4605
|
});
|
|
3014
|
-
|
|
4606
|
+
let webPort;
|
|
4607
|
+
if (options.webUI !== false) {
|
|
4608
|
+
const result = await startWebUI(port, options.webPort || DEFAULT_WEB_PORT, options.quiet);
|
|
4609
|
+
webPort = result.port;
|
|
4610
|
+
}
|
|
4611
|
+
return { app, port, host, webPort };
|
|
4612
|
+
}
|
|
4613
|
+
function stopServer() {
|
|
4614
|
+
stopWebUI();
|
|
4615
|
+
listSessions().then(async (sessions3) => {
|
|
4616
|
+
for (const id of sessions3) {
|
|
4617
|
+
await killTerminal(id);
|
|
4618
|
+
}
|
|
4619
|
+
}).catch(() => {
|
|
4620
|
+
});
|
|
4621
|
+
if (serverInstance) {
|
|
4622
|
+
serverInstance.close();
|
|
4623
|
+
serverInstance = null;
|
|
4624
|
+
}
|
|
4625
|
+
closeDatabase();
|
|
3015
4626
|
}
|
|
3016
4627
|
function generateOpenAPISpec() {
|
|
3017
4628
|
return {
|
|
3018
4629
|
openapi: "3.1.0",
|
|
3019
4630
|
info: {
|
|
3020
|
-
title: "
|
|
4631
|
+
title: "SparkECoder API",
|
|
3021
4632
|
version: "0.1.0",
|
|
3022
4633
|
description: "A powerful coding agent CLI with HTTP API for development environments. Supports streaming responses following the Vercel AI SDK data stream protocol."
|
|
3023
4634
|
},
|
|
@@ -3468,8 +5079,8 @@ function generateOpenAPISpec() {
|
|
|
3468
5079
|
}
|
|
3469
5080
|
|
|
3470
5081
|
// src/cli.ts
|
|
3471
|
-
import { writeFileSync, existsSync as
|
|
3472
|
-
import { resolve as
|
|
5082
|
+
import { writeFileSync as writeFileSync2, existsSync as existsSync8 } from "fs";
|
|
5083
|
+
import { resolve as resolve7, join as join4 } from "path";
|
|
3473
5084
|
async function apiRequest(baseUrl, path, options = {}) {
|
|
3474
5085
|
const url = `${baseUrl}${path}`;
|
|
3475
5086
|
const init = {
|
|
@@ -3489,11 +5100,143 @@ async function isServerRunning(baseUrl) {
|
|
|
3489
5100
|
return false;
|
|
3490
5101
|
}
|
|
3491
5102
|
}
|
|
5103
|
+
async function getActiveStream(baseUrl, sessionId) {
|
|
5104
|
+
try {
|
|
5105
|
+
const response = await apiRequest(baseUrl, `/agents/${sessionId}/stream`);
|
|
5106
|
+
if (response.ok) {
|
|
5107
|
+
const data = await response.json();
|
|
5108
|
+
return {
|
|
5109
|
+
hasActiveStream: data.hasActiveStream,
|
|
5110
|
+
streamId: data.stream?.streamId
|
|
5111
|
+
};
|
|
5112
|
+
}
|
|
5113
|
+
} catch {
|
|
5114
|
+
}
|
|
5115
|
+
return { hasActiveStream: false };
|
|
5116
|
+
}
|
|
5117
|
+
function promptApproval(rl, toolName, input) {
|
|
5118
|
+
return new Promise((resolve8) => {
|
|
5119
|
+
const inputStr = JSON.stringify(input);
|
|
5120
|
+
const truncatedInput = inputStr.length > 100 ? inputStr.slice(0, 100) + "..." : inputStr;
|
|
5121
|
+
console.log(chalk.dim(` Command: ${truncatedInput}`));
|
|
5122
|
+
rl.question(chalk.yellow(` Approve? [y/n]: `), (answer) => {
|
|
5123
|
+
const approved = answer.toLowerCase().startsWith("y");
|
|
5124
|
+
resolve8(approved);
|
|
5125
|
+
});
|
|
5126
|
+
});
|
|
5127
|
+
}
|
|
5128
|
+
async function consumeSSEStream(response, options = {}) {
|
|
5129
|
+
if (!response.body) {
|
|
5130
|
+
throw new Error("No response body");
|
|
5131
|
+
}
|
|
5132
|
+
const reader = response.body.getReader();
|
|
5133
|
+
const decoder = new TextDecoder();
|
|
5134
|
+
let buffer = "";
|
|
5135
|
+
let currentText = "";
|
|
5136
|
+
let currentReasoning = "";
|
|
5137
|
+
let firstResponse = true;
|
|
5138
|
+
let hasError = false;
|
|
5139
|
+
while (true) {
|
|
5140
|
+
const { done, value } = await reader.read();
|
|
5141
|
+
if (done) break;
|
|
5142
|
+
buffer += decoder.decode(value, { stream: true });
|
|
5143
|
+
const lines = buffer.split("\n");
|
|
5144
|
+
buffer = lines.pop() || "";
|
|
5145
|
+
for (const line of lines) {
|
|
5146
|
+
if (line.startsWith("data: ")) {
|
|
5147
|
+
const data = line.slice(6);
|
|
5148
|
+
if (data === "[DONE]") continue;
|
|
5149
|
+
try {
|
|
5150
|
+
const event = JSON.parse(data);
|
|
5151
|
+
if (event.type === "data-stream-id" && event.streamId) {
|
|
5152
|
+
options.onStreamId?.(event.streamId);
|
|
5153
|
+
}
|
|
5154
|
+
if (firstResponse && (event.type === "text-delta" || event.type === "tool-input-available" || event.type === "reasoning-delta")) {
|
|
5155
|
+
process.stdout.write("\r" + " ".repeat(20) + "\r");
|
|
5156
|
+
process.stdout.write(chalk.cyan("\u{1F436}SparkECoder: "));
|
|
5157
|
+
firstResponse = false;
|
|
5158
|
+
options.onFirstResponse?.();
|
|
5159
|
+
}
|
|
5160
|
+
if (event.type === "reasoning-delta" && event.delta) {
|
|
5161
|
+
currentReasoning += event.delta;
|
|
5162
|
+
} else if (event.type === "reasoning-end" && currentReasoning) {
|
|
5163
|
+
console.log(chalk.dim(`
|
|
5164
|
+
[Thinking: ${currentReasoning.slice(0, 100)}${currentReasoning.length > 100 ? "..." : ""}]`));
|
|
5165
|
+
currentReasoning = "";
|
|
5166
|
+
}
|
|
5167
|
+
if (event.type === "text-delta" && (event.delta || event.text)) {
|
|
5168
|
+
const text2 = event.delta || event.text || "";
|
|
5169
|
+
process.stdout.write(text2);
|
|
5170
|
+
currentText += text2;
|
|
5171
|
+
}
|
|
5172
|
+
if (event.type === "tool-input-available") {
|
|
5173
|
+
if (currentText) {
|
|
5174
|
+
process.stdout.write("\n");
|
|
5175
|
+
currentText = "";
|
|
5176
|
+
}
|
|
5177
|
+
const inputStr = JSON.stringify(event.input).slice(0, 80);
|
|
5178
|
+
console.log(chalk.yellow(` \u{1F527} ${event.toolName}`), chalk.dim(inputStr));
|
|
5179
|
+
} else if (event.type === "tool-output-available") {
|
|
5180
|
+
const output = JSON.stringify(event.output);
|
|
5181
|
+
const truncated = output.length > 100 ? output.slice(0, 100) + "..." : output;
|
|
5182
|
+
console.log(chalk.dim(` \u2192 ${truncated}`));
|
|
5183
|
+
}
|
|
5184
|
+
if (event.type === "data-approval-required") {
|
|
5185
|
+
const approval = event.data;
|
|
5186
|
+
console.log(chalk.yellow(`
|
|
5187
|
+
\u26A0\uFE0F Approval required for: ${approval.toolName}`));
|
|
5188
|
+
if (options.interactive && options.baseUrl && options.sessionId && options.readline) {
|
|
5189
|
+
const approved = await promptApproval(options.readline, approval.toolName, approval.input);
|
|
5190
|
+
const endpoint = approved ? "approve" : "reject";
|
|
5191
|
+
const apiResponse = await apiRequest(options.baseUrl, `/agents/${options.sessionId}/${endpoint}/${approval.toolCallId}`, { method: "POST" });
|
|
5192
|
+
if (apiResponse.ok) {
|
|
5193
|
+
console.log(approved ? chalk.green(" \u2713 Approved") : chalk.red(" \u2717 Rejected"));
|
|
5194
|
+
} else {
|
|
5195
|
+
console.log(chalk.red(` Failed to ${endpoint}: ${apiResponse.statusText}`));
|
|
5196
|
+
}
|
|
5197
|
+
} else {
|
|
5198
|
+
console.log(chalk.dim(` (Approve in web UI or use: /approve ${approval.toolCallId})`));
|
|
5199
|
+
}
|
|
5200
|
+
}
|
|
5201
|
+
if (event.type === "finish") {
|
|
5202
|
+
} else if (event.type === "abort") {
|
|
5203
|
+
console.log(chalk.dim("\n[Stream aborted]"));
|
|
5204
|
+
} else if (event.type === "error") {
|
|
5205
|
+
if (firstResponse) {
|
|
5206
|
+
process.stdout.write("\r" + " ".repeat(20) + "\r");
|
|
5207
|
+
firstResponse = false;
|
|
5208
|
+
}
|
|
5209
|
+
hasError = true;
|
|
5210
|
+
console.log(chalk.red(`
|
|
5211
|
+
Error: ${event.errorText || "Unknown error"}`));
|
|
5212
|
+
}
|
|
5213
|
+
} catch (parseErr) {
|
|
5214
|
+
console.error(chalk.red(`
|
|
5215
|
+
[Parse error] ${parseErr.message}: ${data.slice(0, 100)}`));
|
|
5216
|
+
}
|
|
5217
|
+
}
|
|
5218
|
+
}
|
|
5219
|
+
}
|
|
5220
|
+
if (firstResponse) {
|
|
5221
|
+
process.stdout.write("\r" + " ".repeat(20) + "\r");
|
|
5222
|
+
}
|
|
5223
|
+
if (currentText) {
|
|
5224
|
+
process.stdout.write("\n");
|
|
5225
|
+
} else if (!hasError) {
|
|
5226
|
+
console.log(chalk.dim("(no response)"));
|
|
5227
|
+
}
|
|
5228
|
+
}
|
|
5229
|
+
var serverStartedByUs = false;
|
|
3492
5230
|
async function runChat(options) {
|
|
5231
|
+
await ensureDependencies({ quiet: true });
|
|
5232
|
+
loadApiKeysIntoEnv();
|
|
3493
5233
|
const baseUrl = `http://${options.host}:${options.port}`;
|
|
3494
5234
|
try {
|
|
3495
5235
|
const running = await isServerRunning(baseUrl);
|
|
3496
|
-
if (
|
|
5236
|
+
if (running) {
|
|
5237
|
+
const webUrl = `http://localhost:${options.webPort || "6969"}`;
|
|
5238
|
+
console.log(`\u2714 Web UI: ${chalk.cyan(webUrl)}`);
|
|
5239
|
+
} else {
|
|
3497
5240
|
if (options.autoStart === false) {
|
|
3498
5241
|
console.error(chalk.red(`Server not running at ${baseUrl}`));
|
|
3499
5242
|
console.error(chalk.dim("Start with: sparkecoder server"));
|
|
@@ -3505,14 +5248,63 @@ async function runChat(options) {
|
|
|
3505
5248
|
port: parseInt(options.port),
|
|
3506
5249
|
host: options.host,
|
|
3507
5250
|
configPath: options.config,
|
|
3508
|
-
workingDirectory: options.workingDir
|
|
5251
|
+
workingDirectory: options.workingDir,
|
|
5252
|
+
quiet: true,
|
|
5253
|
+
// Clean chat output
|
|
5254
|
+
webUI: options.web !== false,
|
|
5255
|
+
// Server handles web UI
|
|
5256
|
+
webPort: parseInt(options.webPort || "6969")
|
|
3509
5257
|
});
|
|
3510
|
-
|
|
5258
|
+
serverStartedByUs = true;
|
|
5259
|
+
const webUrl = `http://localhost:${options.webPort || "6969"}`;
|
|
5260
|
+
spinner.succeed(`Web UI: ${chalk.cyan(webUrl)}`);
|
|
5261
|
+
const cleanup = () => {
|
|
5262
|
+
if (serverStartedByUs) {
|
|
5263
|
+
stopServer();
|
|
5264
|
+
}
|
|
5265
|
+
process.exit(0);
|
|
5266
|
+
};
|
|
5267
|
+
process.on("SIGINT", cleanup);
|
|
5268
|
+
process.on("SIGTERM", cleanup);
|
|
3511
5269
|
} catch (err) {
|
|
3512
5270
|
spinner.fail(chalk.red(`Failed to start server: ${err.message}`));
|
|
3513
5271
|
process.exit(1);
|
|
3514
5272
|
}
|
|
3515
5273
|
}
|
|
5274
|
+
const healthResponse = await fetch(`${baseUrl}/health`);
|
|
5275
|
+
const healthData = await healthResponse.json();
|
|
5276
|
+
if (!healthData.apiKeyConfigured) {
|
|
5277
|
+
console.log(chalk.yellow("\n\u26A0\uFE0F No AI Gateway API key configured."));
|
|
5278
|
+
console.log(chalk.dim("An API key is required to use SparkECoder.\n"));
|
|
5279
|
+
console.log(chalk.dim("Get your API key from: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys\n"));
|
|
5280
|
+
const keyRl = createInterface({
|
|
5281
|
+
input: process.stdin,
|
|
5282
|
+
output: process.stdout
|
|
5283
|
+
});
|
|
5284
|
+
const apiKey = await new Promise((resolve8) => {
|
|
5285
|
+
keyRl.question(chalk.cyan("Enter your AI Gateway API key: "), (answer) => {
|
|
5286
|
+
resolve8(answer.trim());
|
|
5287
|
+
});
|
|
5288
|
+
});
|
|
5289
|
+
keyRl.close();
|
|
5290
|
+
if (!apiKey) {
|
|
5291
|
+
console.error(chalk.red("\nAPI key is required. Exiting."));
|
|
5292
|
+
process.exit(1);
|
|
5293
|
+
}
|
|
5294
|
+
const saveResponse = await apiRequest(baseUrl, "/health/api-keys", {
|
|
5295
|
+
method: "POST",
|
|
5296
|
+
body: { provider: "ai-gateway", apiKey }
|
|
5297
|
+
});
|
|
5298
|
+
if (!saveResponse.ok) {
|
|
5299
|
+
console.error(chalk.red("\nFailed to save API key. Please try again."));
|
|
5300
|
+
process.exit(1);
|
|
5301
|
+
}
|
|
5302
|
+
console.log(chalk.green("\u2713 API key saved successfully.\n"));
|
|
5303
|
+
}
|
|
5304
|
+
const rl = createInterface({
|
|
5305
|
+
input: process.stdin,
|
|
5306
|
+
output: process.stdout
|
|
5307
|
+
});
|
|
3516
5308
|
let sessionId;
|
|
3517
5309
|
if (options.session) {
|
|
3518
5310
|
const response = await apiRequest(baseUrl, `/sessions/${options.session}`);
|
|
@@ -3521,8 +5313,25 @@ async function runChat(options) {
|
|
|
3521
5313
|
process.exit(1);
|
|
3522
5314
|
}
|
|
3523
5315
|
const session = await response.json();
|
|
3524
|
-
sessionId = session.
|
|
3525
|
-
console.log(chalk.dim(`Resuming session: ${session.
|
|
5316
|
+
sessionId = session.id;
|
|
5317
|
+
console.log(chalk.dim(`Resuming session: ${session.name || sessionId}`));
|
|
5318
|
+
const streamInfo = await getActiveStream(baseUrl, sessionId);
|
|
5319
|
+
if (streamInfo.hasActiveStream && streamInfo.streamId) {
|
|
5320
|
+
console.log(chalk.cyan(`
|
|
5321
|
+
\u{1F4E1} Active stream detected, connecting...`));
|
|
5322
|
+
console.log(chalk.dim(` Stream ID: ${streamInfo.streamId}`));
|
|
5323
|
+
console.log(chalk.dim(` (You're watching a stream from another client)
|
|
5324
|
+
`));
|
|
5325
|
+
try {
|
|
5326
|
+
const watchResponse = await fetch(`${baseUrl}/agents/${sessionId}/watch?streamId=${streamInfo.streamId}`);
|
|
5327
|
+
if (watchResponse.ok) {
|
|
5328
|
+
await consumeSSEStream(watchResponse, { interactive: true, baseUrl, sessionId, readline: rl });
|
|
5329
|
+
}
|
|
5330
|
+
} catch (err) {
|
|
5331
|
+
console.log(chalk.dim("Stream ended or connection lost."));
|
|
5332
|
+
}
|
|
5333
|
+
console.log("");
|
|
5334
|
+
}
|
|
3526
5335
|
} else {
|
|
3527
5336
|
const config = loadConfig(options.config, options.workingDir);
|
|
3528
5337
|
const response = await apiRequest(baseUrl, "/sessions", {
|
|
@@ -3542,140 +5351,208 @@ async function runChat(options) {
|
|
|
3542
5351
|
sessionId = data.id;
|
|
3543
5352
|
}
|
|
3544
5353
|
console.log("");
|
|
3545
|
-
console.log(chalk.bold.cyan("\u{
|
|
5354
|
+
console.log(chalk.bold.cyan("\u{1F436} SparkECoder"));
|
|
3546
5355
|
console.log(chalk.dim("Commands: /quit, /clear, /session, /tools, /help"));
|
|
3547
5356
|
console.log("");
|
|
3548
|
-
const rl = createInterface({
|
|
3549
|
-
input: process.stdin,
|
|
3550
|
-
output: process.stdout
|
|
3551
|
-
});
|
|
3552
5357
|
const prompt = () => {
|
|
3553
5358
|
rl.question(chalk.green("You: "), async (input) => {
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
return;
|
|
3558
|
-
}
|
|
3559
|
-
if (trimmed.startsWith("/")) {
|
|
3560
|
-
const cmd = trimmed.toLowerCase();
|
|
3561
|
-
if (cmd === "/quit" || cmd === "/exit" || cmd === "/q") {
|
|
3562
|
-
console.log(chalk.dim("Goodbye!"));
|
|
3563
|
-
rl.close();
|
|
3564
|
-
process.exit(0);
|
|
3565
|
-
}
|
|
3566
|
-
if (cmd === "/help" || cmd === "/?") {
|
|
3567
|
-
console.log(chalk.dim("Commands:"));
|
|
3568
|
-
console.log(chalk.dim(" /quit, /exit, /q - Exit chat"));
|
|
3569
|
-
console.log(chalk.dim(" /clear - Clear conversation"));
|
|
3570
|
-
console.log(chalk.dim(" /session - Show session info"));
|
|
3571
|
-
console.log(chalk.dim(" /tools - List available tools"));
|
|
3572
|
-
console.log(chalk.dim(" /help, /? - Show this help"));
|
|
3573
|
-
prompt();
|
|
3574
|
-
return;
|
|
3575
|
-
}
|
|
3576
|
-
if (cmd === "/clear") {
|
|
3577
|
-
const response = await apiRequest(baseUrl, `/sessions/${sessionId}/clear`, { method: "POST" });
|
|
3578
|
-
if (response.ok) {
|
|
3579
|
-
console.log(chalk.dim("Conversation cleared."));
|
|
3580
|
-
} else {
|
|
3581
|
-
console.log(chalk.red("Failed to clear conversation."));
|
|
3582
|
-
}
|
|
5359
|
+
try {
|
|
5360
|
+
const trimmed = input.trim();
|
|
5361
|
+
if (!trimmed) {
|
|
3583
5362
|
prompt();
|
|
3584
5363
|
return;
|
|
3585
5364
|
}
|
|
3586
|
-
if (
|
|
3587
|
-
const
|
|
3588
|
-
if (
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
console.log(chalk.dim(`Model: ${session.model}`));
|
|
3593
|
-
console.log(chalk.dim(`Dir: ${session.workingDirectory}`));
|
|
5365
|
+
if (trimmed.startsWith("/")) {
|
|
5366
|
+
const cmd = trimmed.toLowerCase();
|
|
5367
|
+
if (cmd === "/quit" || cmd === "/exit" || cmd === "/q") {
|
|
5368
|
+
console.log(chalk.dim("Goodbye!"));
|
|
5369
|
+
rl.close();
|
|
5370
|
+
process.exit(0);
|
|
3594
5371
|
}
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
console.log(chalk.dim(
|
|
3603
|
-
|
|
3604
|
-
console.log(chalk.dim("
|
|
5372
|
+
if (cmd === "/help" || cmd === "/?") {
|
|
5373
|
+
console.log(chalk.dim("Commands:"));
|
|
5374
|
+
console.log(chalk.dim(" /quit, /exit, /q - Exit chat"));
|
|
5375
|
+
console.log(chalk.dim(" /clear - Clear conversation"));
|
|
5376
|
+
console.log(chalk.dim(" /session - Show session info"));
|
|
5377
|
+
console.log(chalk.dim(" /stream - Check for active stream"));
|
|
5378
|
+
console.log(chalk.dim(" /watch - Watch active stream (if any)"));
|
|
5379
|
+
console.log(chalk.dim(" /tools - List available tools"));
|
|
5380
|
+
console.log(chalk.dim(" /approve <id> - Approve pending tool call"));
|
|
5381
|
+
console.log(chalk.dim(" /reject <id> - Reject pending tool call"));
|
|
5382
|
+
console.log(chalk.dim(" /approvals - List pending approvals"));
|
|
5383
|
+
console.log(chalk.dim(" /help, /? - Show this help"));
|
|
5384
|
+
prompt();
|
|
5385
|
+
return;
|
|
3605
5386
|
}
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
prompt();
|
|
3611
|
-
return;
|
|
3612
|
-
}
|
|
3613
|
-
process.stdout.write(chalk.cyan("Agent: "));
|
|
3614
|
-
let currentText = "";
|
|
3615
|
-
let hasError = false;
|
|
3616
|
-
try {
|
|
3617
|
-
const response = await fetch(`${baseUrl}/agents/${sessionId}/run`, {
|
|
3618
|
-
method: "POST",
|
|
3619
|
-
headers: { "Content-Type": "application/json" },
|
|
3620
|
-
body: JSON.stringify({ prompt: trimmed })
|
|
3621
|
-
});
|
|
3622
|
-
if (!response.ok) {
|
|
3623
|
-
const error = await response.json();
|
|
3624
|
-
throw new Error(error.error || `HTTP ${response.status}`);
|
|
3625
|
-
}
|
|
3626
|
-
if (!response.body) {
|
|
3627
|
-
throw new Error("No response body");
|
|
3628
|
-
}
|
|
3629
|
-
const reader = response.body.getReader();
|
|
3630
|
-
const decoder = new TextDecoder();
|
|
3631
|
-
let buffer = "";
|
|
3632
|
-
while (true) {
|
|
3633
|
-
const { done, value } = await reader.read();
|
|
3634
|
-
if (done) break;
|
|
3635
|
-
buffer += decoder.decode(value, { stream: true });
|
|
3636
|
-
const lines = buffer.split("\n");
|
|
3637
|
-
buffer = lines.pop() || "";
|
|
3638
|
-
for (const line of lines) {
|
|
3639
|
-
if (line.startsWith("data: ")) {
|
|
3640
|
-
const data = line.slice(6);
|
|
3641
|
-
if (data === "[DONE]") continue;
|
|
5387
|
+
if (cmd === "/watch") {
|
|
5388
|
+
const streamInfo = await getActiveStream(baseUrl, sessionId);
|
|
5389
|
+
if (streamInfo.hasActiveStream && streamInfo.streamId) {
|
|
5390
|
+
console.log(chalk.cyan(`\u{1F4E1} Connecting to active stream...`));
|
|
3642
5391
|
try {
|
|
3643
|
-
const
|
|
3644
|
-
if (
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
if (currentText) {
|
|
3649
|
-
process.stdout.write("\n");
|
|
3650
|
-
currentText = "";
|
|
3651
|
-
}
|
|
3652
|
-
const inputStr = JSON.stringify(event.input).slice(0, 80);
|
|
3653
|
-
console.log(chalk.yellow(` \u{1F527} ${event.toolName}`), chalk.dim(inputStr));
|
|
3654
|
-
} else if (event.type === "tool-result") {
|
|
3655
|
-
const output = JSON.stringify(event.output);
|
|
3656
|
-
const truncated = output.length > 100 ? output.slice(0, 100) + "..." : output;
|
|
3657
|
-
console.log(chalk.dim(` \u2192 ${truncated}`));
|
|
3658
|
-
} else if (event.type === "error") {
|
|
3659
|
-
hasError = true;
|
|
3660
|
-
console.log(chalk.red(`
|
|
3661
|
-
Error: ${event.error}`));
|
|
5392
|
+
const watchResponse = await fetch(`${baseUrl}/agents/${sessionId}/watch?streamId=${streamInfo.streamId}`);
|
|
5393
|
+
if (watchResponse.ok) {
|
|
5394
|
+
await consumeSSEStream(watchResponse, { interactive: true, baseUrl, sessionId, readline: rl });
|
|
5395
|
+
} else {
|
|
5396
|
+
console.log(chalk.dim("Failed to connect to stream."));
|
|
3662
5397
|
}
|
|
3663
|
-
} catch {
|
|
5398
|
+
} catch (err) {
|
|
5399
|
+
console.log(chalk.dim("Stream ended or connection lost."));
|
|
3664
5400
|
}
|
|
5401
|
+
} else {
|
|
5402
|
+
console.log(chalk.dim("No active stream to watch."));
|
|
3665
5403
|
}
|
|
5404
|
+
prompt();
|
|
5405
|
+
return;
|
|
3666
5406
|
}
|
|
5407
|
+
if (cmd.startsWith("/approve ")) {
|
|
5408
|
+
const toolCallId = trimmed.slice(9).trim();
|
|
5409
|
+
if (!toolCallId) {
|
|
5410
|
+
console.log(chalk.yellow("Usage: /approve <toolCallId>"));
|
|
5411
|
+
prompt();
|
|
5412
|
+
return;
|
|
5413
|
+
}
|
|
5414
|
+
const response = await apiRequest(baseUrl, `/agents/${sessionId}/approve/${toolCallId}`, { method: "POST" });
|
|
5415
|
+
if (response.ok) {
|
|
5416
|
+
console.log(chalk.green("\u2713 Tool approved"));
|
|
5417
|
+
} else {
|
|
5418
|
+
const error = await response.json();
|
|
5419
|
+
console.log(chalk.red(`Failed: ${error.error || "Unknown error"}`));
|
|
5420
|
+
}
|
|
5421
|
+
prompt();
|
|
5422
|
+
return;
|
|
5423
|
+
}
|
|
5424
|
+
if (cmd.startsWith("/reject ")) {
|
|
5425
|
+
const toolCallId = trimmed.slice(8).trim();
|
|
5426
|
+
if (!toolCallId) {
|
|
5427
|
+
console.log(chalk.yellow("Usage: /reject <toolCallId>"));
|
|
5428
|
+
prompt();
|
|
5429
|
+
return;
|
|
5430
|
+
}
|
|
5431
|
+
const response = await apiRequest(baseUrl, `/agents/${sessionId}/reject/${toolCallId}`, {
|
|
5432
|
+
method: "POST",
|
|
5433
|
+
body: { reason: "Rejected via CLI" }
|
|
5434
|
+
});
|
|
5435
|
+
if (response.ok) {
|
|
5436
|
+
console.log(chalk.green("\u2713 Tool rejected"));
|
|
5437
|
+
} else {
|
|
5438
|
+
const error = await response.json();
|
|
5439
|
+
console.log(chalk.red(`Failed: ${error.error || "Unknown error"}`));
|
|
5440
|
+
}
|
|
5441
|
+
prompt();
|
|
5442
|
+
return;
|
|
5443
|
+
}
|
|
5444
|
+
if (cmd === "/approvals") {
|
|
5445
|
+
const response = await apiRequest(baseUrl, `/agents/${sessionId}/approvals`);
|
|
5446
|
+
if (response.ok) {
|
|
5447
|
+
const data = await response.json();
|
|
5448
|
+
if (data.pendingApprovals.length === 0) {
|
|
5449
|
+
console.log(chalk.dim("No pending approvals."));
|
|
5450
|
+
} else {
|
|
5451
|
+
console.log(chalk.bold("Pending approvals:"));
|
|
5452
|
+
for (const approval of data.pendingApprovals) {
|
|
5453
|
+
console.log(chalk.yellow(` ${approval.toolName}`), chalk.dim(`(${approval.toolCallId})`));
|
|
5454
|
+
}
|
|
5455
|
+
}
|
|
5456
|
+
}
|
|
5457
|
+
prompt();
|
|
5458
|
+
return;
|
|
5459
|
+
}
|
|
5460
|
+
if (cmd === "/clear") {
|
|
5461
|
+
const response = await apiRequest(baseUrl, `/sessions/${sessionId}/clear`, { method: "POST" });
|
|
5462
|
+
if (response.ok) {
|
|
5463
|
+
console.log(chalk.dim("Conversation cleared."));
|
|
5464
|
+
} else {
|
|
5465
|
+
console.log(chalk.red("Failed to clear conversation."));
|
|
5466
|
+
}
|
|
5467
|
+
prompt();
|
|
5468
|
+
return;
|
|
5469
|
+
}
|
|
5470
|
+
if (cmd === "/session" || cmd.startsWith("/session ")) {
|
|
5471
|
+
if (cmd.startsWith("/session ")) {
|
|
5472
|
+
const arg = trimmed.slice(9).trim();
|
|
5473
|
+
console.log(chalk.yellow(`To switch to session "${arg}", restart with: sparkecoder -s ${arg}`));
|
|
5474
|
+
console.log(chalk.dim("Showing current session info:"));
|
|
5475
|
+
}
|
|
5476
|
+
const response = await apiRequest(baseUrl, `/sessions/${sessionId}`);
|
|
5477
|
+
if (response.ok) {
|
|
5478
|
+
const session = await response.json();
|
|
5479
|
+
console.log(chalk.dim(`Session: ${session.id}`));
|
|
5480
|
+
console.log(chalk.dim(`Name: ${session.name || "(unnamed)"}`));
|
|
5481
|
+
console.log(chalk.dim(`Model: ${session.model}`));
|
|
5482
|
+
console.log(chalk.dim(`Dir: ${session.workingDirectory}`));
|
|
5483
|
+
console.log(chalk.dim(`Status: ${session.status}`));
|
|
5484
|
+
const streamInfo = await getActiveStream(baseUrl, sessionId);
|
|
5485
|
+
if (streamInfo.hasActiveStream) {
|
|
5486
|
+
console.log(chalk.cyan(`Stream: Active (${streamInfo.streamId})`));
|
|
5487
|
+
}
|
|
5488
|
+
} else {
|
|
5489
|
+
console.log(chalk.red("Failed to get session info."));
|
|
5490
|
+
}
|
|
5491
|
+
prompt();
|
|
5492
|
+
return;
|
|
5493
|
+
}
|
|
5494
|
+
if (cmd === "/stream") {
|
|
5495
|
+
const streamInfo = await getActiveStream(baseUrl, sessionId);
|
|
5496
|
+
if (streamInfo.hasActiveStream && streamInfo.streamId) {
|
|
5497
|
+
console.log(chalk.cyan(`\u{1F4E1} Active stream: ${streamInfo.streamId}`));
|
|
5498
|
+
console.log(chalk.dim("Use /watch to connect to this stream"));
|
|
5499
|
+
} else {
|
|
5500
|
+
console.log(chalk.dim("No active stream."));
|
|
5501
|
+
}
|
|
5502
|
+
prompt();
|
|
5503
|
+
return;
|
|
5504
|
+
}
|
|
5505
|
+
if (cmd === "/tools") {
|
|
5506
|
+
const response = await apiRequest(baseUrl, `/sessions/${sessionId}/tools`);
|
|
5507
|
+
if (response.ok) {
|
|
5508
|
+
const data = await response.json();
|
|
5509
|
+
if (data.tools && Array.isArray(data.tools)) {
|
|
5510
|
+
console.log(chalk.dim(`Tools: ${data.tools.map((t) => t.name).join(", ")}`));
|
|
5511
|
+
} else {
|
|
5512
|
+
console.log(chalk.dim("Tools: bash, read_file, write_file, todo, load_skill"));
|
|
5513
|
+
}
|
|
5514
|
+
} else {
|
|
5515
|
+
console.log(chalk.dim("Tools: bash, read_file, write_file, todo, load_skill"));
|
|
5516
|
+
}
|
|
5517
|
+
prompt();
|
|
5518
|
+
return;
|
|
5519
|
+
}
|
|
5520
|
+
console.log(chalk.yellow(`Unknown command. Type /help for commands.`));
|
|
5521
|
+
prompt();
|
|
5522
|
+
return;
|
|
3667
5523
|
}
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
5524
|
+
process.stdout.write(chalk.dim("Thinking..."));
|
|
5525
|
+
try {
|
|
5526
|
+
const response = await fetch(`${baseUrl}/agents/${sessionId}/run`, {
|
|
5527
|
+
method: "POST",
|
|
5528
|
+
headers: { "Content-Type": "application/json" },
|
|
5529
|
+
body: JSON.stringify({ prompt: trimmed })
|
|
5530
|
+
});
|
|
5531
|
+
if (!response.ok) {
|
|
5532
|
+
process.stdout.write("\r" + " ".repeat(20) + "\r");
|
|
5533
|
+
const error = await response.json();
|
|
5534
|
+
throw new Error(error.error || `HTTP ${response.status}`);
|
|
5535
|
+
}
|
|
5536
|
+
await consumeSSEStream(response, { interactive: true, baseUrl, sessionId, readline: rl });
|
|
5537
|
+
} catch (error) {
|
|
5538
|
+
process.stdout.write("\r" + " ".repeat(20) + "\r");
|
|
5539
|
+
console.log(chalk.red(`
|
|
3675
5540
|
Error: ${error.message}`));
|
|
5541
|
+
if (error.stack) {
|
|
5542
|
+
console.log(chalk.dim(error.stack));
|
|
5543
|
+
}
|
|
5544
|
+
}
|
|
5545
|
+
console.log("");
|
|
5546
|
+
prompt();
|
|
5547
|
+
} catch (outerError) {
|
|
5548
|
+
console.error(chalk.red(`
|
|
5549
|
+
Unexpected error: ${outerError.message}`));
|
|
5550
|
+
if (outerError.stack) {
|
|
5551
|
+
console.log(chalk.dim(outerError.stack));
|
|
5552
|
+
}
|
|
5553
|
+
console.log("");
|
|
5554
|
+
prompt();
|
|
3676
5555
|
}
|
|
3677
|
-
console.log("");
|
|
3678
|
-
prompt();
|
|
3679
5556
|
});
|
|
3680
5557
|
};
|
|
3681
5558
|
prompt();
|
|
@@ -3685,44 +5562,66 @@ Error: ${error.message}`));
|
|
|
3685
5562
|
}
|
|
3686
5563
|
}
|
|
3687
5564
|
var program = new Command();
|
|
3688
|
-
program.name("sparkecoder").description("AI coding agent - just type sparkecoder to start chatting").version("0.1.0").option("-s, --session <id>", "Resume an existing session").option("-n, --name <name>", "Name for the new session").option("-m, --model <model>", "Model to use").option("-w, --working-dir <path>", "Working directory").option("-c, --config <path>", "Path to config file").option("-p, --port <port>", "Server port", "3141").option("-H, --host <host>", "Server host", "127.0.0.1").option("--no-auto-start", "Do not auto-start server if not running").action(async (options) => {
|
|
5565
|
+
program.name("sparkecoder").description("AI coding agent - just type sparkecoder to start chatting").version("0.1.0").option("-s, --session <id>", "Resume an existing session").option("-n, --name <name>", "Name for the new session").option("-m, --model <model>", "Model to use").option("-w, --working-dir <path>", "Working directory").option("-c, --config <path>", "Path to config file").option("-p, --port <port>", "Server port", "3141").option("-H, --host <host>", "Server host", "127.0.0.1").option("--no-auto-start", "Do not auto-start server if not running").option("--web-port <port>", "Web UI port", "6969").option("--no-web", "Do not start web UI when auto-starting server").action(async (options) => {
|
|
3689
5566
|
await runChat(options);
|
|
3690
5567
|
});
|
|
3691
|
-
program.command("server").description("Start the
|
|
3692
|
-
|
|
5568
|
+
program.command("server").description("Start the SparkECoder server (API + Web UI)").option("-p, --port <port>", "API server port", "3141").option("-h, --host <host>", "Server host", "127.0.0.1").option("-c, --config <path>", "Path to config file").option("-w, --working-dir <path>", "Working directory").option("--web-port <port>", "Web UI port", "6969").option("--no-web", "Do not start web UI").action(async (options) => {
|
|
5569
|
+
await ensureDependencies({ quiet: false });
|
|
5570
|
+
const spinner = ora("Starting SparkECoder server...").start();
|
|
3693
5571
|
try {
|
|
3694
|
-
const { port, host } = await startServer({
|
|
5572
|
+
const { port, host, webPort } = await startServer({
|
|
3695
5573
|
port: parseInt(options.port),
|
|
3696
5574
|
host: options.host,
|
|
3697
5575
|
configPath: options.config,
|
|
3698
|
-
workingDirectory: options.workingDir
|
|
5576
|
+
workingDirectory: options.workingDir,
|
|
5577
|
+
webUI: options.web !== false,
|
|
5578
|
+
webPort: parseInt(options.webPort) || 6969
|
|
3699
5579
|
});
|
|
3700
|
-
|
|
5580
|
+
if (webPort) {
|
|
5581
|
+
spinner.succeed(chalk.green(`SparkECoder running at http://localhost:${webPort}`));
|
|
5582
|
+
} else {
|
|
5583
|
+
spinner.succeed(chalk.green(`SparkECoder server running`));
|
|
5584
|
+
}
|
|
3701
5585
|
console.log("");
|
|
3702
|
-
console.log(chalk.dim(
|
|
3703
|
-
console.log(chalk.dim(`
|
|
3704
|
-
console.log(chalk.dim(` OpenAPI: http://${host}:${port}/openapi.json`));
|
|
3705
|
-
console.log(chalk.dim(` Swagger: http://${host}:${port}/swagger`));
|
|
3706
|
-
console.log(chalk.dim(` Sessions: http://${host}:${port}/sessions`));
|
|
5586
|
+
console.log(chalk.dim(` API: http://${host}:${port}`));
|
|
5587
|
+
console.log(chalk.dim(` Swagger: http://${host}:${port}/swagger`));
|
|
3707
5588
|
console.log("");
|
|
3708
5589
|
console.log(chalk.dim("Press Ctrl+C to stop"));
|
|
5590
|
+
const cleanup = () => {
|
|
5591
|
+
console.log(chalk.dim("\nShutting down..."));
|
|
5592
|
+
stopServer();
|
|
5593
|
+
process.exit(0);
|
|
5594
|
+
};
|
|
5595
|
+
process.on("SIGINT", cleanup);
|
|
5596
|
+
process.on("SIGTERM", cleanup);
|
|
3709
5597
|
} catch (error) {
|
|
3710
5598
|
spinner.fail(chalk.red(`Failed to start server: ${error.message}`));
|
|
3711
5599
|
process.exit(1);
|
|
3712
5600
|
}
|
|
3713
5601
|
});
|
|
3714
|
-
program.command("chat").description("Start an interactive chat session").option("-s, --session <id>", "Resume an existing session").option("-n, --name <name>", "Name for the new session").option("-m, --model <model>", "Model to use").option("-w, --working-dir <path>", "Working directory").option("-c, --config <path>", "Path to config file").option("-p, --port <port>", "Server port", "3141").option("-H, --host <host>", "Server host", "127.0.0.1").option("--no-auto-start", "Do not auto-start server if not running").action(async (options) => {
|
|
5602
|
+
program.command("chat").description("Start an interactive chat session").option("-s, --session <id>", "Resume an existing session").option("-n, --name <name>", "Name for the new session").option("-m, --model <model>", "Model to use").option("-w, --working-dir <path>", "Working directory").option("-c, --config <path>", "Path to config file").option("-p, --port <port>", "Server port", "3141").option("-H, --host <host>", "Server host", "127.0.0.1").option("--no-auto-start", "Do not auto-start server if not running").option("--web-port <port>", "Web UI port", "6969").option("--no-web", "Do not start web UI when auto-starting server").action(async (options) => {
|
|
3715
5603
|
await runChat(options);
|
|
3716
5604
|
});
|
|
3717
|
-
program.command("init").description("Create a sparkecoder.config.json file").option("-f, --force", "Overwrite existing config").action((options) => {
|
|
3718
|
-
|
|
3719
|
-
|
|
5605
|
+
program.command("init").description("Create a sparkecoder.config.json file").option("-f, --force", "Overwrite existing config").option("-g, --global", "Create global config in app data directory").action((options) => {
|
|
5606
|
+
let configPath;
|
|
5607
|
+
let configLocation;
|
|
5608
|
+
if (options.global) {
|
|
5609
|
+
const appDataDir = ensureAppDataDirectory();
|
|
5610
|
+
configPath = join4(appDataDir, "sparkecoder.config.json");
|
|
5611
|
+
configLocation = "global";
|
|
5612
|
+
} else {
|
|
5613
|
+
configPath = resolve7(process.cwd(), "sparkecoder.config.json");
|
|
5614
|
+
configLocation = "local";
|
|
5615
|
+
}
|
|
5616
|
+
if (existsSync8(configPath) && !options.force) {
|
|
3720
5617
|
console.log(chalk.yellow("Config file already exists. Use --force to overwrite."));
|
|
5618
|
+
console.log(chalk.dim(` ${configPath}`));
|
|
3721
5619
|
return;
|
|
3722
5620
|
}
|
|
3723
5621
|
const config = createDefaultConfig();
|
|
3724
|
-
|
|
3725
|
-
console.log(chalk.green(
|
|
5622
|
+
writeFileSync2(configPath, JSON.stringify(config, null, 2));
|
|
5623
|
+
console.log(chalk.green(`\u2713 Created ${configLocation} config`));
|
|
5624
|
+
console.log(chalk.dim(` ${configPath}`));
|
|
3726
5625
|
console.log(chalk.dim("Set AI_GATEWAY_API_KEY and run sparkecoder to start"));
|
|
3727
5626
|
});
|
|
3728
5627
|
program.command("sessions").description("List all sessions").option("-l, --limit <limit>", "Number of sessions to show", "20").option("-p, --port <port>", "Server port", "3141").option("-H, --host <host>", "Server host", "127.0.0.1").action(async (options) => {
|
|
@@ -3775,5 +5674,69 @@ program.command("config").description("Show current configuration").option("-c,
|
|
|
3775
5674
|
process.exit(1);
|
|
3776
5675
|
}
|
|
3777
5676
|
});
|
|
5677
|
+
program.command("api-key").description("Manage API keys for AI providers").argument("[provider]", "Provider name (anthropic, openai, google, xai, ai-gateway)").argument("[key]", "API key to set (if not provided, shows status)").option("-l, --list", "List all API key statuses").action((provider, key, options) => {
|
|
5678
|
+
try {
|
|
5679
|
+
ensureAppDataDirectory();
|
|
5680
|
+
if (options.list || !provider && !key) {
|
|
5681
|
+
console.log(chalk.bold("\nAPI Key Status:\n"));
|
|
5682
|
+
const status = getApiKeyStatus();
|
|
5683
|
+
for (const s of status) {
|
|
5684
|
+
const providerName = s.provider.charAt(0).toUpperCase() + s.provider.slice(1);
|
|
5685
|
+
if (s.configured) {
|
|
5686
|
+
const sourceLabel = s.source === "env" ? chalk.blue("(from env)") : chalk.green("(saved)");
|
|
5687
|
+
console.log(` ${chalk.green("\u2713")} ${chalk.bold(providerName)} ${sourceLabel}`);
|
|
5688
|
+
console.log(` ${chalk.dim(s.maskedKey)}`);
|
|
5689
|
+
console.log(` ${chalk.dim(`env: ${s.envVar}`)}`);
|
|
5690
|
+
} else {
|
|
5691
|
+
console.log(` ${chalk.dim("\u25CB")} ${chalk.dim(providerName)} ${chalk.dim("(not configured)")}`);
|
|
5692
|
+
console.log(` ${chalk.dim(`env: ${s.envVar}`)}`);
|
|
5693
|
+
}
|
|
5694
|
+
console.log();
|
|
5695
|
+
}
|
|
5696
|
+
console.log(chalk.dim(`Supported providers: ${SUPPORTED_PROVIDERS.join(", ")}`));
|
|
5697
|
+
console.log(chalk.dim(`Usage: sparkecoder api-key <provider> <key>`));
|
|
5698
|
+
return;
|
|
5699
|
+
}
|
|
5700
|
+
if (provider && key) {
|
|
5701
|
+
setApiKey(provider, key);
|
|
5702
|
+
const status = getApiKeyStatus();
|
|
5703
|
+
const providerStatus = status.find((s) => s.provider === provider.toLowerCase());
|
|
5704
|
+
console.log(chalk.green(`
|
|
5705
|
+
\u2713 API key for ${provider} saved successfully`));
|
|
5706
|
+
if (providerStatus?.maskedKey) {
|
|
5707
|
+
console.log(` Key: ${chalk.dim(providerStatus.maskedKey)}`);
|
|
5708
|
+
}
|
|
5709
|
+
console.log(chalk.dim(`
|
|
5710
|
+
The key is stored securely and will be loaded automatically on startup.`));
|
|
5711
|
+
return;
|
|
5712
|
+
}
|
|
5713
|
+
if (provider && !key) {
|
|
5714
|
+
const status = getApiKeyStatus();
|
|
5715
|
+
const providerStatus = status.find((s) => s.provider === provider.toLowerCase());
|
|
5716
|
+
if (!providerStatus) {
|
|
5717
|
+
console.error(chalk.red(`Unknown provider: ${provider}`));
|
|
5718
|
+
console.log(chalk.dim(`Supported providers: ${SUPPORTED_PROVIDERS.join(", ")}`));
|
|
5719
|
+
process.exit(1);
|
|
5720
|
+
}
|
|
5721
|
+
const providerName = providerStatus.provider.charAt(0).toUpperCase() + providerStatus.provider.slice(1);
|
|
5722
|
+
console.log(chalk.bold(`
|
|
5723
|
+
${providerName} API Key:
|
|
5724
|
+
`));
|
|
5725
|
+
if (providerStatus.configured) {
|
|
5726
|
+
const sourceLabel = providerStatus.source === "env" ? chalk.blue("(from env)") : chalk.green("(saved)");
|
|
5727
|
+
console.log(` Status: ${chalk.green("Configured")} ${sourceLabel}`);
|
|
5728
|
+
console.log(` Key: ${chalk.dim(providerStatus.maskedKey)}`);
|
|
5729
|
+
} else {
|
|
5730
|
+
console.log(` Status: ${chalk.yellow("Not configured")}`);
|
|
5731
|
+
}
|
|
5732
|
+
console.log(` Environment variable: ${chalk.dim(providerStatus.envVar)}`);
|
|
5733
|
+
console.log();
|
|
5734
|
+
console.log(chalk.dim(`To set: sparkecoder api-key ${provider} <your-api-key>`));
|
|
5735
|
+
}
|
|
5736
|
+
} catch (error) {
|
|
5737
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
5738
|
+
process.exit(1);
|
|
5739
|
+
}
|
|
5740
|
+
});
|
|
3778
5741
|
program.parse();
|
|
3779
5742
|
//# sourceMappingURL=cli.js.map
|