@lelouchhe/webagent 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,81 +0,0 @@
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 DELETED
@@ -1,81 +0,0 @@
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
- }
package/src/ws-handler.ts DELETED
@@ -1,264 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { WebSocket, WebSocketServer } from "ws";
3
- import { WsMessageSchema, errorMessage } from "./types.ts";
4
- import type { AgentEvent } from "./types.ts";
5
- import type { AgentBridge } from "./bridge.ts";
6
- import type { Store } from "./store.ts";
7
- import type { SessionManager } from "./session-manager.ts";
8
- import type { TitleService } from "./title-service.ts";
9
- import type { Config } from "./config.ts";
10
-
11
- interface WsHandlerDeps {
12
- wss: WebSocketServer;
13
- store: Store;
14
- sessions: SessionManager;
15
- titleService: TitleService;
16
- getBridge: () => AgentBridge | null;
17
- limits: Config["limits"];
18
- }
19
-
20
- function interruptBashProc(proc: ReturnType<SessionManager["runningBashProcs"]["get"]>): void {
21
- if (!proc) return;
22
- if (typeof proc.pid === "number") {
23
- try {
24
- process.kill(-proc.pid, "SIGINT");
25
- return;
26
- } catch {
27
- // Fall through to direct child kill when the process is not a group leader.
28
- }
29
- }
30
- proc.kill("SIGINT");
31
- }
32
-
33
- export function broadcast(wss: WebSocketServer, event: AgentEvent, exclude?: WebSocket): void {
34
- const msg = JSON.stringify(event);
35
- for (const client of wss.clients) {
36
- if (client.readyState === WebSocket.OPEN && client !== exclude) {
37
- try { client.send(msg); } catch { /* client gone mid-send */ }
38
- }
39
- }
40
- }
41
-
42
- function send(ws: WebSocket, event: AgentEvent): void {
43
- if (ws.readyState === WebSocket.OPEN) {
44
- try { ws.send(JSON.stringify(event)); } catch { /* client gone mid-send */ }
45
- }
46
- }
47
-
48
- export function setupWsHandler(deps: WsHandlerDeps): void {
49
- const { wss, store, sessions, titleService, getBridge, limits } = deps;
50
-
51
- wss.on("connection", (ws) => {
52
- console.log(`[ws] client connected (total: ${wss.clients.size})`);
53
-
54
- const pingInterval = setInterval(() => {
55
- if (ws.readyState === WebSocket.OPEN) ws.ping();
56
- }, 30_000);
57
-
58
- ws.on("message", async (raw) => {
59
- // Parse & validate
60
- let parsed: unknown;
61
- try {
62
- parsed = JSON.parse(raw.toString());
63
- } catch {
64
- send(ws, { type: "error", message: "Invalid JSON" });
65
- return;
66
- }
67
-
68
- const result = WsMessageSchema.safeParse(parsed);
69
- if (!result.success) {
70
- send(ws, { type: "error", message: `Invalid message: ${result.error.message}` });
71
- return;
72
- }
73
- const msg = result.data;
74
-
75
- try {
76
- const bridge = getBridge();
77
-
78
- switch (msg.type) {
79
- case "new_session": {
80
- if (!bridge) { send(ws, { type: "error", message: "Agent not ready yet" }); return; }
81
- const created = await sessions.createSession(bridge, msg.cwd, msg.inheritFromSessionId);
82
- if (created.configOptions.length) {
83
- send(ws, {
84
- type: "config_option_update",
85
- sessionId: created.sessionId,
86
- configOptions: created.configOptions,
87
- });
88
- }
89
- break;
90
- }
91
-
92
- case "resume_session": {
93
- if (!bridge) { send(ws, { type: "error", message: "Agent not ready yet" }); return; }
94
- try {
95
- const event = await sessions.resumeSession(bridge, msg.sessionId);
96
- send(ws, event);
97
- } catch {
98
- send(ws, { type: "session_expired", sessionId: msg.sessionId });
99
- }
100
- break;
101
- }
102
-
103
- case "delete_session": {
104
- sessions.deleteSession(msg.sessionId);
105
- broadcast(wss, { type: "session_deleted", sessionId: msg.sessionId });
106
- console.log(`[session] deleted: ${msg.sessionId.slice(0, 8)}…`);
107
- break;
108
- }
109
-
110
- case "prompt": {
111
- if (!bridge) { send(ws, { type: "error", message: "No active bridge" }); return; }
112
- const images = msg.images;
113
- const userData = {
114
- text: msg.text,
115
- ...(images && { images: images.map((i) => ({ path: i.path, mimeType: i.mimeType })) }),
116
- };
117
- store.saveEvent(msg.sessionId, "user_message", userData);
118
- store.updateSessionLastActive(msg.sessionId);
119
- // Generate title once the session actually gets one; canceled/failed attempts can retry later.
120
- if (!sessions.sessionHasTitle.has(msg.sessionId)) {
121
- titleService.generate(bridge, msg.text, msg.sessionId, (title) => {
122
- broadcast(wss, { type: "session_title_updated", sessionId: msg.sessionId, title });
123
- });
124
- }
125
- // Broadcast to other clients
126
- const userEvent = JSON.stringify({ type: "user_message", sessionId: msg.sessionId, ...userData });
127
- for (const client of wss.clients) {
128
- if (client !== ws && client.readyState === WebSocket.OPEN) {
129
- client.send(userEvent);
130
- }
131
- }
132
- sessions.activePrompts.add(msg.sessionId);
133
- bridge.prompt(msg.sessionId, msg.text, images).catch((err: unknown) => {
134
- send(ws, { type: "error", message: errorMessage(err) });
135
- });
136
- break;
137
- }
138
-
139
- case "permission_response": {
140
- if (!bridge) return;
141
- if (msg.denied) {
142
- bridge.denyPermission(msg.requestId);
143
- } else if (msg.optionId) {
144
- bridge.resolvePermission(msg.requestId, msg.optionId);
145
- }
146
- if (msg.sessionId) {
147
- store.saveEvent(msg.sessionId, "permission_response", {
148
- requestId: msg.requestId,
149
- optionName: msg.optionName || "",
150
- denied: !!msg.denied,
151
- });
152
- }
153
- broadcast(wss, {
154
- type: "permission_resolved",
155
- sessionId: msg.sessionId,
156
- requestId: msg.requestId,
157
- optionName: msg.optionName || "",
158
- denied: !!msg.denied,
159
- } as any);
160
- break;
161
- }
162
-
163
- case "cancel": {
164
- interruptBashProc(sessions.runningBashProcs.get(msg.sessionId));
165
- if (bridge) {
166
- await titleService.cancel(msg.sessionId, bridge);
167
- }
168
- await bridge?.cancel(msg.sessionId);
169
- break;
170
- }
171
-
172
- case "set_config_option": {
173
- if (!bridge) { send(ws, { type: "error", message: "Agent not ready yet" }); return; }
174
- try {
175
- const configOptions = await bridge.setConfigOption(msg.sessionId, msg.configId, msg.value);
176
- for (const opt of configOptions) {
177
- store.updateSessionConfig(msg.sessionId, opt.id, opt.currentValue);
178
- }
179
- send(ws, { type: "config_set", configId: msg.configId, value: msg.value } as any);
180
- if (configOptions.length) {
181
- broadcast(wss, { type: "config_option_update", sessionId: msg.sessionId, configOptions }, ws);
182
- }
183
- } catch (err: unknown) {
184
- send(ws, { type: "error", message: `Failed to set ${msg.configId}: ${errorMessage(err)}` });
185
- }
186
- break;
187
- }
188
-
189
- case "bash_exec": {
190
- if (sessions.runningBashProcs.has(msg.sessionId)) {
191
- send(ws, { type: "error", message: "A bash command is already running in this session" });
192
- return;
193
- }
194
- const cwd = sessions.getSessionCwd(msg.sessionId);
195
- store.saveEvent(msg.sessionId, "bash_command", { command: msg.command });
196
- // Broadcast to other clients
197
- const bashEvent = JSON.stringify({
198
- type: "bash_command", sessionId: msg.sessionId, command: msg.command,
199
- });
200
- for (const client of wss.clients) {
201
- if (client !== ws && client.readyState === WebSocket.OPEN) {
202
- client.send(bashEvent);
203
- }
204
- }
205
-
206
- const child = spawn("bash", ["-c", msg.command], {
207
- cwd,
208
- detached: true,
209
- env: { ...process.env, TERM: "dumb" },
210
- stdio: ["ignore", "pipe", "pipe"],
211
- });
212
- sessions.runningBashProcs.set(msg.sessionId, child);
213
- let output = "";
214
- let outputTruncated = false;
215
-
216
- const onData = (stream: string) => (chunk: Buffer) => {
217
- const text = chunk.toString();
218
- if (!outputTruncated) {
219
- output += text;
220
- if (output.length > limits.bash_output) {
221
- output = output.slice(-limits.bash_output);
222
- outputTruncated = true;
223
- }
224
- } else {
225
- // Keep only the tail within the limit
226
- output = (output + text).slice(-limits.bash_output);
227
- }
228
- broadcast(wss, { type: "bash_output", sessionId: msg.sessionId, text, stream } as any);
229
- };
230
- child.stdout!.on("data", onData("stdout"));
231
- child.stderr!.on("data", onData("stderr"));
232
-
233
- child.on("close", (code, signal) => {
234
- sessions.runningBashProcs.delete(msg.sessionId);
235
- const stored = outputTruncated ? "[truncated]\n" + output : output;
236
- store.saveEvent(msg.sessionId, "bash_result", { output: stored, code, signal });
237
- broadcast(wss, { type: "bash_done", sessionId: msg.sessionId, code, signal } as any);
238
- });
239
-
240
- child.on("error", (err) => {
241
- sessions.runningBashProcs.delete(msg.sessionId);
242
- const errMsg = errorMessage(err);
243
- store.saveEvent(msg.sessionId, "bash_result", { output: errMsg, code: -1, signal: null });
244
- broadcast(wss, { type: "bash_done", sessionId: msg.sessionId, code: -1, signal: null, error: errMsg } as any);
245
- });
246
- break;
247
- }
248
-
249
- case "bash_cancel": {
250
- interruptBashProc(sessions.runningBashProcs.get(msg.sessionId));
251
- break;
252
- }
253
- }
254
- } catch (err: unknown) {
255
- send(ws, { type: "error", message: errorMessage(err) });
256
- }
257
- });
258
-
259
- ws.on("close", () => {
260
- clearInterval(pingInterval);
261
- console.log(`[ws] client disconnected (total: ${wss.clients.size})`);
262
- });
263
- });
264
- }