teapot-coding-agent 0.23.0 → 0.24.1

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.
@@ -0,0 +1,198 @@
1
+ import { readEvents } from "../log/events.js";
2
+ import path from "node:path";
3
+ export const ACP_PROTOCOL_VERSION = 1;
4
+ /** JSON-RPC error codes we use (the standard set plus JSON-RPC's own). */
5
+ const PARSE_ERROR = -32700;
6
+ const METHOD_NOT_FOUND = -32601;
7
+ const INVALID_PARAMS = -32602;
8
+ const INTERNAL_ERROR = -32603;
9
+ /**
10
+ * Run the ACP adapter until stdin closes.
11
+ *
12
+ * Kept as a class rather than free functions so the session map and the pending
13
+ * request bookkeeping have somewhere to live, and so it is directly testable
14
+ * with an in-memory stream pair.
15
+ */
16
+ export class AcpAdapter {
17
+ master;
18
+ input;
19
+ output;
20
+ defaultCwd;
21
+ sessions = new Map();
22
+ /** id -> notification params, so a prompt turn can stream updates */
23
+ turnUpdates = new Map();
24
+ buffer = "";
25
+ nextSession = 1;
26
+ initialized = false;
27
+ constructor(opts) {
28
+ this.master = opts.master;
29
+ this.input = opts.input ?? process.stdin;
30
+ this.output = opts.output ?? process.stdout;
31
+ this.defaultCwd = opts.defaultCwd ?? process.cwd();
32
+ }
33
+ /** Attach to the stream. Resolves when stdin ends. */
34
+ listen() {
35
+ return new Promise((resolve) => {
36
+ this.input.on("data", (chunk) => this.onData(String(chunk)));
37
+ this.input.on("end", () => resolve());
38
+ this.input.on("error", () => resolve());
39
+ if (typeof this.input.resume === "function")
40
+ this.input.resume();
41
+ });
42
+ }
43
+ /* ---------- transport ---------- */
44
+ onData(chunk) {
45
+ this.buffer += chunk;
46
+ // NDJSON: one JSON object per line
47
+ let nl;
48
+ while ((nl = this.buffer.indexOf("\n")) >= 0) {
49
+ const line = this.buffer.slice(0, nl).trim();
50
+ this.buffer = this.buffer.slice(nl + 1);
51
+ if (line)
52
+ this.handleLine(line);
53
+ }
54
+ }
55
+ handleLine(line) {
56
+ let msg;
57
+ try {
58
+ msg = JSON.parse(line);
59
+ }
60
+ catch {
61
+ // a parse error has no id to answer
62
+ this.send({ jsonrpc: "2.0", id: null, error: { code: PARSE_ERROR, message: "invalid JSON" } });
63
+ return;
64
+ }
65
+ void this.dispatch(msg);
66
+ }
67
+ send(res) {
68
+ this.output.write(JSON.stringify(res) + "\n");
69
+ }
70
+ notify(method, params) {
71
+ this.send({ jsonrpc: "2.0", method, params });
72
+ }
73
+ ok(id, result) {
74
+ this.send({ jsonrpc: "2.0", id, result });
75
+ }
76
+ fail(id, code, message) {
77
+ this.send({ jsonrpc: "2.0", id, error: { code, message } });
78
+ }
79
+ /* ---------- dispatch ---------- */
80
+ async dispatch(msg) {
81
+ const { method, id, params } = msg;
82
+ const isNotification = id === undefined || id === null;
83
+ try {
84
+ switch (method) {
85
+ case "initialize":
86
+ return this.ok(id ?? null, this.initialize());
87
+ case "authenticate":
88
+ return this.ok(id ?? null, {});
89
+ case "session/new":
90
+ return this.ok(id ?? null, await this.newSession(params));
91
+ case "session/load":
92
+ return this.ok(id ?? null, await this.loadSession(params));
93
+ case "session/prompt":
94
+ return await this.prompt(id ?? null, params);
95
+ case "session/cancel":
96
+ this.cancel(params);
97
+ if (!isNotification)
98
+ return this.ok(id ?? null, {});
99
+ return;
100
+ default:
101
+ // notifications for unknown methods get NO response at all
102
+ if (isNotification)
103
+ return;
104
+ return this.fail(id ?? null, METHOD_NOT_FOUND, `unknown method: ${method}`);
105
+ }
106
+ }
107
+ catch (err) {
108
+ if (isNotification)
109
+ return; // never answer a notification
110
+ return this.fail(id ?? null, INTERNAL_ERROR, err.message);
111
+ }
112
+ }
113
+ initialize() {
114
+ this.initialized = true;
115
+ return {
116
+ protocolVersion: ACP_PROTOCOL_VERSION,
117
+ // Only capabilities we actually implement. Terminal/fs are deliberately
118
+ // absent: a client calls what we advertise, and a partial implementation
119
+ // would be worse than an honest omission.
120
+ agentCapabilities: { loadSession: true, promptCapabilities: { image: false, audio: false } },
121
+ authMethods: [],
122
+ };
123
+ }
124
+ /* ---------- sessions ---------- */
125
+ async newSession(params) {
126
+ const p = (params ?? {});
127
+ const cwd = p.cwd ? path.resolve(String(p.cwd)) : this.defaultCwd;
128
+ const id = `acp-${this.nextSession++}`;
129
+ const agent = await this.master.addAgent({ id, workspace: cwd }, { persist: false, fresh: true });
130
+ this.sessions.set(id, { id, cwd, agent });
131
+ return { sessionId: id };
132
+ }
133
+ async loadSession(params) {
134
+ const p = (params ?? {});
135
+ const id = String(p.sessionId ?? "");
136
+ const existing = this.sessions.get(id);
137
+ if (existing)
138
+ return null; // already live
139
+ const cwd = p.cwd ? path.resolve(String(p.cwd)) : this.defaultCwd;
140
+ // resuming means the agent's own log is restored, not a fresh session
141
+ const agent = await this.master.addAgent({ id, workspace: cwd }, { persist: false, fresh: false });
142
+ this.sessions.set(id, { id, cwd, agent });
143
+ return null;
144
+ }
145
+ cancel(params) {
146
+ const p = (params ?? {});
147
+ const s = p.sessionId ? this.sessions.get(String(p.sessionId)) : undefined;
148
+ s?.agent.stop("cancelled via ACP");
149
+ }
150
+ /* ---------- prompt turn ---------- */
151
+ async prompt(id, params) {
152
+ const p = (params ?? {});
153
+ const s = p.sessionId ? this.sessions.get(String(p.sessionId)) : undefined;
154
+ if (!s)
155
+ return this.fail(id, INVALID_PARAMS, `unknown session: ${String(p.sessionId)}`);
156
+ const text = (p.prompt ?? [])
157
+ .map((c) => String(c?.text ?? ""))
158
+ .join("\n")
159
+ .trim();
160
+ if (!text)
161
+ return this.fail(id, INVALID_PARAMS, "prompt must contain text");
162
+ this.turnUpdates.set(id, { sessionId: s.id });
163
+ try {
164
+ s.agent.enqueuePrompt(text, "user");
165
+ if (s.agent.status !== "running")
166
+ s.agent.start("acp prompt");
167
+ await s.agent.settled();
168
+ const stopReason = s.agent.goal.status === "done" ? "end_turn" : "end_turn";
169
+ return this.ok(id, { stopReason });
170
+ }
171
+ finally {
172
+ this.turnUpdates.delete(id);
173
+ }
174
+ }
175
+ /** Stream a session's events to the client as session/update notifications. */
176
+ async drainOnce(sessionId, afterSeq = 0) {
177
+ const s = this.sessions.get(sessionId);
178
+ if (!s)
179
+ return afterSeq;
180
+ const events = await readEvents(s.agent.log.filePath).catch(() => []);
181
+ for (const e of events) {
182
+ if (e.seq <= afterSeq)
183
+ continue;
184
+ this.notify("session/update", {
185
+ sessionId,
186
+ update: {
187
+ sessionUpdate: e.type === "message" ? "agent_message_chunk" : "tool_call",
188
+ content: { type: "text", text: String(e.data?.content ?? "") },
189
+ },
190
+ });
191
+ }
192
+ const max = events.reduce((m, e) => Math.max(m, e.seq), afterSeq);
193
+ return max;
194
+ }
195
+ get initialized_() {
196
+ return this.initialized;
197
+ }
198
+ }