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