@lelouchhe/webagent 0.1.0
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/LICENSE +21 -0
- package/README.md +244 -0
- package/bin/webagent.mjs +23 -0
- package/config.toml +28 -0
- package/dist/icons/icon-180.png +0 -0
- package/dist/icons/icon-192.png +0 -0
- package/dist/icons/icon-512.png +0 -0
- package/dist/icons/icon.svg +4 -0
- package/dist/index.html +46 -0
- package/dist/js/app.mmjqzu9r.js +10 -0
- package/dist/js/commands.mmjqzu9r.js +454 -0
- package/dist/js/connection.mmjqzu9r.js +76 -0
- package/dist/js/events.mmjqzu9r.js +612 -0
- package/dist/js/images.mmjqzu9r.js +58 -0
- package/dist/js/input.mmjqzu9r.js +196 -0
- package/dist/js/render.mmjqzu9r.js +200 -0
- package/dist/js/state.mmjqzu9r.js +176 -0
- package/dist/manifest.json +26 -0
- package/dist/styles.mmjqzu9r.css +555 -0
- package/dist/sw.js +5 -0
- package/package.json +56 -0
- package/src/bridge.ts +317 -0
- package/src/config.ts +65 -0
- package/src/routes.ts +147 -0
- package/src/server.ts +159 -0
- package/src/session-manager.ts +223 -0
- package/src/store.ts +140 -0
- package/src/title-service.ts +81 -0
- package/src/types.ts +81 -0
- package/src/ws-handler.ts +264 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import { stat } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { Store } from "./store.ts";
|
|
6
|
+
import type { AgentBridge } from "./bridge.ts";
|
|
7
|
+
import type { AgentEvent, ConfigOption } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
type SessionBridge = Pick<AgentBridge, "newSession" | "setConfigOption" | "loadSession">;
|
|
10
|
+
|
|
11
|
+
/** Known config option IDs that we persist per-session. */
|
|
12
|
+
const PERSISTED_CONFIG_IDS = ["model", "mode", "reasoning_effort"] as const;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Centralizes all session-related state that was previously scattered
|
|
16
|
+
* across module-level variables in server.ts.
|
|
17
|
+
*/
|
|
18
|
+
export class SessionManager {
|
|
19
|
+
readonly liveSessions = new Set<string>();
|
|
20
|
+
readonly restoringSessions = new Set<string>();
|
|
21
|
+
readonly sessionHasTitle = new Set<string>();
|
|
22
|
+
readonly assistantBuffers = new Map<string, string>();
|
|
23
|
+
readonly thinkingBuffers = new Map<string, string>();
|
|
24
|
+
readonly activePrompts = new Set<string>();
|
|
25
|
+
readonly runningBashProcs = new Map<string, ChildProcess>();
|
|
26
|
+
|
|
27
|
+
cachedConfigOptions: ConfigOption[] = [];
|
|
28
|
+
|
|
29
|
+
private store: Store;
|
|
30
|
+
private defaultCwd: string;
|
|
31
|
+
private dataDir: string;
|
|
32
|
+
|
|
33
|
+
constructor(store: Store, defaultCwd: string, dataDir: string) {
|
|
34
|
+
this.store = store;
|
|
35
|
+
this.defaultCwd = defaultCwd;
|
|
36
|
+
this.dataDir = dataDir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Populate sessionHasTitle from existing DB sessions on startup. */
|
|
40
|
+
hydrate(): void {
|
|
41
|
+
for (const s of this.store.listSessions()) {
|
|
42
|
+
if (s.title) this.sessionHasTitle.add(s.id);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Create a new session in both bridge and store, inheriting the source session's config. */
|
|
47
|
+
async createSession(
|
|
48
|
+
bridge: SessionBridge,
|
|
49
|
+
cwd?: string,
|
|
50
|
+
inheritFromSessionId?: string,
|
|
51
|
+
): Promise<{ sessionId: string; configOptions: ConfigOption[] }> {
|
|
52
|
+
const sessionCwd = cwd ?? this.defaultCwd;
|
|
53
|
+
try {
|
|
54
|
+
const info = await stat(sessionCwd);
|
|
55
|
+
if (!info.isDirectory()) throw new Error("not a directory");
|
|
56
|
+
} catch {
|
|
57
|
+
throw new Error(`Directory does not exist: ${sessionCwd}`);
|
|
58
|
+
}
|
|
59
|
+
const sourceSession = inheritFromSessionId
|
|
60
|
+
? this.store.getSession(inheritFromSessionId)
|
|
61
|
+
: null;
|
|
62
|
+
const sessionId = await bridge.newSession(sessionCwd);
|
|
63
|
+
this.liveSessions.add(sessionId);
|
|
64
|
+
this.store.createSession(sessionId, sessionCwd);
|
|
65
|
+
|
|
66
|
+
// Inherit config options from source session
|
|
67
|
+
if (sourceSession) {
|
|
68
|
+
const inherited: Array<{ configId: string; value: string | null }> = [
|
|
69
|
+
{ configId: "model", value: sourceSession.model },
|
|
70
|
+
{ configId: "reasoning_effort", value: sourceSession.reasoning_effort },
|
|
71
|
+
];
|
|
72
|
+
for (const { configId, value } of inherited) {
|
|
73
|
+
if (!value) continue;
|
|
74
|
+
try {
|
|
75
|
+
await bridge.setConfigOption(sessionId, configId, value);
|
|
76
|
+
this.store.updateSessionConfig(sessionId, configId, value);
|
|
77
|
+
} catch {
|
|
78
|
+
// Option may no longer be available; ignore
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const session = this.store.getSession(sessionId);
|
|
84
|
+
return {
|
|
85
|
+
sessionId,
|
|
86
|
+
configOptions: session ? this.buildConfigOptions(session) : [],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Resume a session — returns event to send to the requesting client. */
|
|
91
|
+
async resumeSession(
|
|
92
|
+
bridge: SessionBridge,
|
|
93
|
+
sessionId: string,
|
|
94
|
+
): Promise<AgentEvent> {
|
|
95
|
+
const session = this.store.getSession(sessionId);
|
|
96
|
+
if (!session) throw new Error("Session not found");
|
|
97
|
+
|
|
98
|
+
if (this.liveSessions.has(sessionId)) {
|
|
99
|
+
// Session already live — build configOptions with stored overrides
|
|
100
|
+
const configOptions = this.buildConfigOptions(session);
|
|
101
|
+
return {
|
|
102
|
+
type: "session_created",
|
|
103
|
+
sessionId,
|
|
104
|
+
cwd: session.cwd,
|
|
105
|
+
title: session.title,
|
|
106
|
+
configOptions,
|
|
107
|
+
busyKind: this.getBusyKind(sessionId) ?? undefined,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Restore via ACP
|
|
112
|
+
this.restoringSessions.add(sessionId);
|
|
113
|
+
try {
|
|
114
|
+
const restored = await bridge.loadSession(sessionId, session.cwd);
|
|
115
|
+
this.liveSessions.add(sessionId);
|
|
116
|
+
if (session.title) this.sessionHasTitle.add(sessionId);
|
|
117
|
+
const configOptions = this.applyStoredConfig(restored.configOptions, session);
|
|
118
|
+
console.log(`[session] restored: ${sessionId.slice(0, 8)}…`);
|
|
119
|
+
return {
|
|
120
|
+
type: "session_created",
|
|
121
|
+
sessionId,
|
|
122
|
+
cwd: session.cwd,
|
|
123
|
+
title: session.title,
|
|
124
|
+
configOptions,
|
|
125
|
+
busyKind: this.getBusyKind(sessionId) ?? undefined,
|
|
126
|
+
};
|
|
127
|
+
} catch (err) {
|
|
128
|
+
console.error(`[session] restore failed:`, err);
|
|
129
|
+
throw err;
|
|
130
|
+
} finally {
|
|
131
|
+
this.restoringSessions.delete(sessionId);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Build configOptions from cache, overriding currentValue with stored session values. */
|
|
136
|
+
private buildConfigOptions(session: { model: string | null; mode: string | null; reasoning_effort: string | null }): ConfigOption[] {
|
|
137
|
+
return this.applyStoredConfig(this.cachedConfigOptions, session);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Override currentValue in configOptions with stored session values. */
|
|
141
|
+
private applyStoredConfig(
|
|
142
|
+
configOptions: ConfigOption[],
|
|
143
|
+
session: { model: string | null; mode: string | null; reasoning_effort: string | null },
|
|
144
|
+
): ConfigOption[] {
|
|
145
|
+
if (!configOptions.length) return this.cachedConfigOptions;
|
|
146
|
+
const stored: Record<string, string | null> = {
|
|
147
|
+
model: session.model,
|
|
148
|
+
mode: session.mode,
|
|
149
|
+
reasoning_effort: session.reasoning_effort,
|
|
150
|
+
};
|
|
151
|
+
return configOptions.map((opt) => {
|
|
152
|
+
const override = stored[opt.id];
|
|
153
|
+
if (override) return { ...opt, currentValue: override };
|
|
154
|
+
return opt;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Delete a session from store and clean up all state (including images). */
|
|
159
|
+
deleteSession(sessionId: string): void {
|
|
160
|
+
this.store.deleteSession(sessionId);
|
|
161
|
+
this.liveSessions.delete(sessionId);
|
|
162
|
+
this.sessionHasTitle.delete(sessionId);
|
|
163
|
+
this.assistantBuffers.delete(sessionId);
|
|
164
|
+
this.thinkingBuffers.delete(sessionId);
|
|
165
|
+
this.activePrompts.delete(sessionId);
|
|
166
|
+
this.runningBashProcs.delete(sessionId);
|
|
167
|
+
// Remove uploaded images for this session
|
|
168
|
+
rm(join(this.dataDir, "images", sessionId), { recursive: true, force: true }).catch(() => {});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Flush assistant/thinking buffers to store. */
|
|
172
|
+
flushBuffers(sessionId: string): void {
|
|
173
|
+
this.flushAssistantBuffer(sessionId);
|
|
174
|
+
this.flushThinkingBuffer(sessionId);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Flush only the assistant message buffer to store. */
|
|
178
|
+
flushAssistantBuffer(sessionId: string): void {
|
|
179
|
+
const assistant = this.assistantBuffers.get(sessionId);
|
|
180
|
+
if (assistant) {
|
|
181
|
+
this.store.saveEvent(sessionId, "assistant_message", { text: assistant });
|
|
182
|
+
this.assistantBuffers.delete(sessionId);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Flush only the thinking buffer to store. */
|
|
187
|
+
flushThinkingBuffer(sessionId: string): void {
|
|
188
|
+
const thinking = this.thinkingBuffers.get(sessionId);
|
|
189
|
+
if (thinking) {
|
|
190
|
+
this.store.saveEvent(sessionId, "thinking", { text: thinking });
|
|
191
|
+
this.thinkingBuffers.delete(sessionId);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Append to assistant message buffer. */
|
|
196
|
+
appendAssistant(sessionId: string, text: string): void {
|
|
197
|
+
const buf = (this.assistantBuffers.get(sessionId) ?? "") + text;
|
|
198
|
+
this.assistantBuffers.set(sessionId, buf);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Append to thinking buffer. */
|
|
202
|
+
appendThinking(sessionId: string, text: string): void {
|
|
203
|
+
const buf = (this.thinkingBuffers.get(sessionId) ?? "") + text;
|
|
204
|
+
this.thinkingBuffers.set(sessionId, buf);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Get CWD for a session (falls back to default). */
|
|
208
|
+
getSessionCwd(sessionId: string): string {
|
|
209
|
+
return this.store.getSession(sessionId)?.cwd ?? this.defaultCwd;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
getBusyKind(sessionId: string): "agent" | "bash" | null {
|
|
213
|
+
if (this.runningBashProcs.has(sessionId)) return "bash";
|
|
214
|
+
if (this.activePrompts.has(sessionId)) return "agent";
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Kill all running bash processes (for shutdown). */
|
|
219
|
+
killAllBashProcs(): void {
|
|
220
|
+
for (const [, proc] of this.runningBashProcs) proc.kill("SIGKILL");
|
|
221
|
+
this.runningBashProcs.clear();
|
|
222
|
+
}
|
|
223
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface SessionRow {
|
|
6
|
+
id: string;
|
|
7
|
+
cwd: string;
|
|
8
|
+
title: string | null;
|
|
9
|
+
model: string | null;
|
|
10
|
+
mode: string | null;
|
|
11
|
+
reasoning_effort: string | null;
|
|
12
|
+
created_at: string;
|
|
13
|
+
last_active_at: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface EventRow {
|
|
17
|
+
id: number;
|
|
18
|
+
session_id: string;
|
|
19
|
+
seq: number;
|
|
20
|
+
type: string;
|
|
21
|
+
data: string; // JSON
|
|
22
|
+
created_at: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class Store {
|
|
26
|
+
private db: Database.Database;
|
|
27
|
+
|
|
28
|
+
constructor(dataDir: string) {
|
|
29
|
+
mkdirSync(dataDir, { recursive: true });
|
|
30
|
+
this.db = new Database(join(dataDir, "webagent.db"));
|
|
31
|
+
this.db.pragma("journal_mode = WAL");
|
|
32
|
+
this.migrate();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private migrate(): void {
|
|
36
|
+
this.db.exec(`
|
|
37
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
38
|
+
id TEXT PRIMARY KEY,
|
|
39
|
+
cwd TEXT NOT NULL,
|
|
40
|
+
title TEXT,
|
|
41
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
|
|
42
|
+
last_active_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
43
|
+
);
|
|
44
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
45
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
46
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
47
|
+
seq INTEGER NOT NULL,
|
|
48
|
+
type TEXT NOT NULL,
|
|
49
|
+
data TEXT NOT NULL DEFAULT '{}',
|
|
50
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
51
|
+
);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id, seq);
|
|
53
|
+
`);
|
|
54
|
+
|
|
55
|
+
// Migrate existing tables: add columns if missing
|
|
56
|
+
const cols = this.db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>;
|
|
57
|
+
const colNames = new Set(cols.map(c => c.name));
|
|
58
|
+
if (!colNames.has("title")) {
|
|
59
|
+
this.db.exec("ALTER TABLE sessions ADD COLUMN title TEXT");
|
|
60
|
+
}
|
|
61
|
+
if (!colNames.has("last_active_at")) {
|
|
62
|
+
this.db.exec("ALTER TABLE sessions ADD COLUMN last_active_at TEXT");
|
|
63
|
+
// Backfill from created_at
|
|
64
|
+
this.db.exec("UPDATE sessions SET last_active_at = created_at WHERE last_active_at IS NULL");
|
|
65
|
+
}
|
|
66
|
+
if (!colNames.has("model")) {
|
|
67
|
+
this.db.exec("ALTER TABLE sessions ADD COLUMN model TEXT");
|
|
68
|
+
}
|
|
69
|
+
if (!colNames.has("mode")) {
|
|
70
|
+
this.db.exec("ALTER TABLE sessions ADD COLUMN mode TEXT");
|
|
71
|
+
}
|
|
72
|
+
if (!colNames.has("reasoning_effort")) {
|
|
73
|
+
this.db.exec("ALTER TABLE sessions ADD COLUMN reasoning_effort TEXT");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
createSession(id: string, cwd: string): SessionRow {
|
|
78
|
+
this.db.prepare("INSERT INTO sessions (id, cwd) VALUES (?, ?)").run(id, cwd);
|
|
79
|
+
return this.db.prepare("SELECT * FROM sessions WHERE id = ?").get(id) as SessionRow;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
listSessions(): SessionRow[] {
|
|
83
|
+
return this.db.prepare("SELECT * FROM sessions ORDER BY COALESCE(last_active_at, created_at) DESC").all() as SessionRow[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getSession(id: string): SessionRow | undefined {
|
|
87
|
+
return this.db.prepare("SELECT * FROM sessions WHERE id = ?").get(id) as SessionRow | undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
deleteSession(id: string): void {
|
|
91
|
+
this.db.prepare("DELETE FROM events WHERE session_id = ?").run(id);
|
|
92
|
+
this.db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
updateSessionTitle(id: string, title: string): void {
|
|
96
|
+
this.db.prepare("UPDATE sessions SET title = ? WHERE id = ?").run(title, id);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
updateSessionLastActive(id: string): void {
|
|
100
|
+
this.db.prepare("UPDATE sessions SET last_active_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = ?").run(id);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Update a config option value (model, mode, reasoning_effort) for a session. */
|
|
104
|
+
updateSessionConfig(id: string, configId: string, value: string): void {
|
|
105
|
+
const column = ({ model: "model", mode: "mode", reasoning_effort: "reasoning_effort" } as Record<string, string>)[configId];
|
|
106
|
+
if (!column) return;
|
|
107
|
+
this.db.prepare(`UPDATE sessions SET ${column} = ? WHERE id = ?`).run(value, id);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
saveEvent(sessionId: string, type: string, data: Record<string, unknown> = {}): EventRow {
|
|
111
|
+
const seq = (this.db.prepare(
|
|
112
|
+
"SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM events WHERE session_id = ?"
|
|
113
|
+
).get(sessionId) as { next: number }).next;
|
|
114
|
+
|
|
115
|
+
this.db.prepare(
|
|
116
|
+
"INSERT INTO events (session_id, seq, type, data) VALUES (?, ?, ?, ?)"
|
|
117
|
+
).run(sessionId, seq, type, JSON.stringify(data));
|
|
118
|
+
|
|
119
|
+
return this.db.prepare("SELECT * FROM events WHERE session_id = ? AND seq = ?")
|
|
120
|
+
.get(sessionId, seq) as EventRow;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
getEvents(sessionId: string, opts?: { excludeThinking?: boolean; afterSeq?: number }): EventRow[] {
|
|
124
|
+
let query = "SELECT * FROM events WHERE session_id = ?";
|
|
125
|
+
const params: unknown[] = [sessionId];
|
|
126
|
+
if (opts?.afterSeq != null) {
|
|
127
|
+
query += " AND seq > ?";
|
|
128
|
+
params.push(opts.afterSeq);
|
|
129
|
+
}
|
|
130
|
+
if (opts?.excludeThinking) {
|
|
131
|
+
query += " AND type != 'thinking'";
|
|
132
|
+
}
|
|
133
|
+
query += " ORDER BY seq";
|
|
134
|
+
return this.db.prepare(query).all(...params) as EventRow[];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
close(): void {
|
|
138
|
+
this.db.close();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { AgentBridge } from "./bridge.ts";
|
|
2
|
+
import type { SessionManager } from "./session-manager.ts";
|
|
3
|
+
import type { Store } from "./store.ts";
|
|
4
|
+
|
|
5
|
+
const TITLE_MODEL = "claude-haiku-4.5";
|
|
6
|
+
|
|
7
|
+
export class TitleService {
|
|
8
|
+
private titleSessionId: string | null = null;
|
|
9
|
+
private activeSourceSessions = new Set<string>();
|
|
10
|
+
private cancelledSourceSessions = new Set<string>();
|
|
11
|
+
private defaultCwd: string;
|
|
12
|
+
|
|
13
|
+
private store: Store;
|
|
14
|
+
private sessions: SessionManager;
|
|
15
|
+
|
|
16
|
+
constructor(store: Store, sessions: SessionManager, defaultCwd: string) {
|
|
17
|
+
this.store = store;
|
|
18
|
+
this.sessions = sessions;
|
|
19
|
+
this.defaultCwd = defaultCwd;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Generate a title for the session (non-blocking, fire-and-forget). */
|
|
23
|
+
generate(bridge: AgentBridge, userMessage: string, sessionId: string, onTitle?: (title: string) => void): void {
|
|
24
|
+
if (this.sessions.sessionHasTitle.has(sessionId) || this.activeSourceSessions.has(sessionId)) return;
|
|
25
|
+
this._generate(bridge, userMessage, sessionId).then((title) => {
|
|
26
|
+
if (title && onTitle) onTitle(title);
|
|
27
|
+
}).catch((err) => {
|
|
28
|
+
console.error(`[title] generation failed:`, err);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private async _generate(
|
|
33
|
+
bridge: AgentBridge,
|
|
34
|
+
userMessage: string,
|
|
35
|
+
sessionId: string,
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
this.activeSourceSessions.add(sessionId);
|
|
38
|
+
const tsId = await this.ensureTitleSession(bridge);
|
|
39
|
+
if (!tsId) {
|
|
40
|
+
this.activeSourceSessions.delete(sessionId);
|
|
41
|
+
this.cancelledSourceSessions.delete(sessionId);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const prompt = `Generate a short title (max 30 chars, no quotes) for a chat that starts with this message. Reply with ONLY the title, nothing else:\n\n${userMessage.slice(0, 500)}`;
|
|
47
|
+
const title = await bridge.promptForText(tsId, prompt);
|
|
48
|
+
if (!title || this.cancelledSourceSessions.has(sessionId)) return;
|
|
49
|
+
|
|
50
|
+
const cleaned = title.replace(/^["']|["']$/g, "").trim().slice(0, 30);
|
|
51
|
+
if (!cleaned) return;
|
|
52
|
+
|
|
53
|
+
this.store.updateSessionTitle(sessionId, cleaned);
|
|
54
|
+
this.sessions.sessionHasTitle.add(sessionId);
|
|
55
|
+
return cleaned;
|
|
56
|
+
} finally {
|
|
57
|
+
this.activeSourceSessions.delete(sessionId);
|
|
58
|
+
this.cancelledSourceSessions.delete(sessionId);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async cancel(sessionId: string, bridge: AgentBridge): Promise<void> {
|
|
63
|
+
this.cancelledSourceSessions.add(sessionId);
|
|
64
|
+
if (!this.titleSessionId || !this.activeSourceSessions.has(sessionId)) return;
|
|
65
|
+
await bridge.cancel(this.titleSessionId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Ensure the dedicated title session exists. Returns session ID or null. */
|
|
69
|
+
private async ensureTitleSession(bridge: AgentBridge): Promise<string | null> {
|
|
70
|
+
if (this.titleSessionId) return this.titleSessionId;
|
|
71
|
+
try {
|
|
72
|
+
const id = await bridge.newSession(this.defaultCwd, { silent: true });
|
|
73
|
+
this.sessions.liveSessions.add(id);
|
|
74
|
+
await bridge.setConfigOption(id, "model", TITLE_MODEL).catch(() => []);
|
|
75
|
+
this.titleSessionId = id;
|
|
76
|
+
return id;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
import type * as acp from "@agentclientprotocol/sdk";
|
|
3
|
+
|
|
4
|
+
// --- Config option (subset of ACP SessionConfigOption we care about) ---
|
|
5
|
+
|
|
6
|
+
export interface ConfigOption {
|
|
7
|
+
type: "select";
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
category?: string | null;
|
|
11
|
+
currentValue: string;
|
|
12
|
+
options: Array<{ value: string; name: string }>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// --- Agent events (server → client) ---
|
|
16
|
+
|
|
17
|
+
export type AgentEvent =
|
|
18
|
+
| { type: "connected"; agent: { name: string; version: string }; configOptions: ConfigOption[]; cancelTimeout?: number }
|
|
19
|
+
| { type: "session_created"; sessionId: string; cwd?: string; title?: string | null; configOptions: ConfigOption[]; busyKind?: "agent" | "bash" }
|
|
20
|
+
| { type: "config_option_update"; sessionId: string; configOptions: ConfigOption[] }
|
|
21
|
+
| { type: "message_chunk"; sessionId: string; text: string }
|
|
22
|
+
| { type: "thought_chunk"; sessionId: string; text: string }
|
|
23
|
+
| { type: "tool_call"; sessionId: string; id: string; title: string; kind: string; rawInput?: unknown }
|
|
24
|
+
| { type: "tool_call_update"; sessionId: string; id: string; status: string; content?: unknown[] }
|
|
25
|
+
| { type: "plan"; sessionId: string; entries: unknown[] }
|
|
26
|
+
| { type: "permission_request"; requestId: string; sessionId: string; title: string; toolCallId?: string | null; options: acp.PermissionOption[] }
|
|
27
|
+
| { type: "prompt_done"; sessionId: string; stopReason: string }
|
|
28
|
+
| { type: "session_deleted"; sessionId: string }
|
|
29
|
+
| { type: "session_title_updated"; sessionId: string; title: string }
|
|
30
|
+
| { type: "session_expired"; sessionId: string }
|
|
31
|
+
| { type: "error"; message: string; sessionId?: string };
|
|
32
|
+
|
|
33
|
+
// --- Inbound WS messages (client → server) ---
|
|
34
|
+
|
|
35
|
+
const ImageSchema = z.object({
|
|
36
|
+
data: z.string(),
|
|
37
|
+
mimeType: z.string(),
|
|
38
|
+
path: z.string().optional(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const WsMessageSchema = z.discriminatedUnion("type", [
|
|
42
|
+
z.object({
|
|
43
|
+
type: z.literal("new_session"),
|
|
44
|
+
cwd: z.string().optional(),
|
|
45
|
+
inheritFromSessionId: z.string().optional(),
|
|
46
|
+
}),
|
|
47
|
+
z.object({ type: z.literal("resume_session"), sessionId: z.string() }),
|
|
48
|
+
z.object({ type: z.literal("delete_session"), sessionId: z.string() }),
|
|
49
|
+
z.object({
|
|
50
|
+
type: z.literal("prompt"),
|
|
51
|
+
sessionId: z.string(),
|
|
52
|
+
text: z.string(),
|
|
53
|
+
images: z.array(ImageSchema).optional(),
|
|
54
|
+
}),
|
|
55
|
+
z.object({
|
|
56
|
+
type: z.literal("permission_response"),
|
|
57
|
+
sessionId: z.string().optional(),
|
|
58
|
+
requestId: z.string(),
|
|
59
|
+
optionId: z.string().optional(),
|
|
60
|
+
optionName: z.string().optional(),
|
|
61
|
+
denied: z.boolean().optional(),
|
|
62
|
+
}),
|
|
63
|
+
z.object({ type: z.literal("cancel"), sessionId: z.string() }),
|
|
64
|
+
z.object({ type: z.literal("set_config_option"), sessionId: z.string(), configId: z.string(), value: z.string() }),
|
|
65
|
+
z.object({ type: z.literal("bash_exec"), sessionId: z.string(), command: z.string() }),
|
|
66
|
+
z.object({ type: z.literal("bash_cancel"), sessionId: z.string() }),
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
export type WsMessage = z.infer<typeof WsMessageSchema>;
|
|
70
|
+
|
|
71
|
+
// --- Utility ---
|
|
72
|
+
|
|
73
|
+
export function errorMessage(err: unknown): string {
|
|
74
|
+
if (err instanceof Error) return err.message;
|
|
75
|
+
if (typeof err === "string") return err;
|
|
76
|
+
try {
|
|
77
|
+
return JSON.stringify(err);
|
|
78
|
+
} catch {
|
|
79
|
+
return String(err);
|
|
80
|
+
}
|
|
81
|
+
}
|