@kenkaiiii/ggcoder 5.29.1 → 5.30.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.
Files changed (124) hide show
  1. package/dist/app-sidecar-auth-broadcast.test.js +14 -3
  2. package/dist/app-sidecar-auth-broadcast.test.js.map +1 -1
  3. package/dist/app-sidecar.js +27 -20
  4. package/dist/app-sidecar.js.map +1 -1
  5. package/dist/cli/command-routing.d.ts +1 -1
  6. package/dist/cli/command-routing.d.ts.map +1 -1
  7. package/dist/cli/command-routing.js +1 -0
  8. package/dist/cli/command-routing.js.map +1 -1
  9. package/dist/cli/command-routing.test.js +1 -0
  10. package/dist/cli/command-routing.test.js.map +1 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +167 -47
  13. package/dist/cli.js.map +1 -1
  14. package/dist/config.d.ts +2 -0
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js +10 -0
  17. package/dist/config.js.map +1 -1
  18. package/dist/config.test.js +10 -1
  19. package/dist/config.test.js.map +1 -1
  20. package/dist/core/agent-session-compaction.test.js +240 -2
  21. package/dist/core/agent-session-compaction.test.js.map +1 -1
  22. package/dist/core/agent-session-crash-durability.test.js +9 -2
  23. package/dist/core/agent-session-crash-durability.test.js.map +1 -1
  24. package/dist/core/agent-session-process-gate.test.js +5 -1
  25. package/dist/core/agent-session-process-gate.test.js.map +1 -1
  26. package/dist/core/agent-session-review-coverage.test.js +5 -1
  27. package/dist/core/agent-session-review-coverage.test.js.map +1 -1
  28. package/dist/core/agent-session.d.ts +10 -3
  29. package/dist/core/agent-session.d.ts.map +1 -1
  30. package/dist/core/agent-session.js +276 -130
  31. package/dist/core/agent-session.js.map +1 -1
  32. package/dist/core/compaction/active-context.test.js +21 -0
  33. package/dist/core/compaction/active-context.test.js.map +1 -1
  34. package/dist/core/compaction/compactor.d.ts +24 -6
  35. package/dist/core/compaction/compactor.d.ts.map +1 -1
  36. package/dist/core/compaction/compactor.js +246 -98
  37. package/dist/core/compaction/compactor.js.map +1 -1
  38. package/dist/core/compaction/compactor.test.js +107 -8
  39. package/dist/core/compaction/compactor.test.js.map +1 -1
  40. package/dist/core/compaction/policy.d.ts +17 -0
  41. package/dist/core/compaction/policy.d.ts.map +1 -0
  42. package/dist/core/compaction/policy.js +22 -0
  43. package/dist/core/compaction/policy.js.map +1 -0
  44. package/dist/core/compaction/policy.test.d.ts +2 -0
  45. package/dist/core/compaction/policy.test.d.ts.map +1 -0
  46. package/dist/core/compaction/policy.test.js +35 -0
  47. package/dist/core/compaction/policy.test.js.map +1 -0
  48. package/dist/core/foreign-session-import.d.ts.map +1 -1
  49. package/dist/core/foreign-session-import.js +5 -1
  50. package/dist/core/foreign-session-import.js.map +1 -1
  51. package/dist/core/ideal-review.d.ts.map +1 -1
  52. package/dist/core/ideal-review.js +4 -0
  53. package/dist/core/ideal-review.js.map +1 -1
  54. package/dist/core/loop-breaker.d.ts.map +1 -1
  55. package/dist/core/loop-breaker.js +1 -0
  56. package/dist/core/loop-breaker.js.map +1 -1
  57. package/dist/core/process-gate.d.ts.map +1 -1
  58. package/dist/core/process-gate.js +9 -1
  59. package/dist/core/process-gate.js.map +1 -1
  60. package/dist/core/process-manager-notifications.test.js +4 -2
  61. package/dist/core/process-manager-notifications.test.js.map +1 -1
  62. package/dist/core/regrounding.d.ts.map +1 -1
  63. package/dist/core/regrounding.js +1 -0
  64. package/dist/core/regrounding.js.map +1 -1
  65. package/dist/core/session-compaction.d.ts +5 -0
  66. package/dist/core/session-compaction.d.ts.map +1 -1
  67. package/dist/core/session-compaction.js +10 -1
  68. package/dist/core/session-compaction.js.map +1 -1
  69. package/dist/core/session-compaction.test.js +16 -1
  70. package/dist/core/session-compaction.test.js.map +1 -1
  71. package/dist/core/session-export.d.ts.map +1 -1
  72. package/dist/core/session-export.js +11 -8
  73. package/dist/core/session-export.js.map +1 -1
  74. package/dist/core/session-history.d.ts +7 -2
  75. package/dist/core/session-history.d.ts.map +1 -1
  76. package/dist/core/session-history.js +52 -9
  77. package/dist/core/session-history.js.map +1 -1
  78. package/dist/core/session-history.test.js +26 -1
  79. package/dist/core/session-history.test.js.map +1 -1
  80. package/dist/core/session-manager.d.ts +106 -0
  81. package/dist/core/session-manager.d.ts.map +1 -1
  82. package/dist/core/session-manager.js +316 -15
  83. package/dist/core/session-manager.js.map +1 -1
  84. package/dist/core/session-manager.test.js +109 -1
  85. package/dist/core/session-manager.test.js.map +1 -1
  86. package/dist/core/session-notification-visibility.test.js +34 -0
  87. package/dist/core/session-notification-visibility.test.js.map +1 -1
  88. package/dist/core/session-preview.d.ts +2 -2
  89. package/dist/core/session-preview.d.ts.map +1 -1
  90. package/dist/core/session-preview.js +8 -5
  91. package/dist/core/session-preview.js.map +1 -1
  92. package/dist/core/session-restore-display.test.js +25 -0
  93. package/dist/core/session-restore-display.test.js.map +1 -1
  94. package/dist/core/session-storage.d.ts.map +1 -1
  95. package/dist/core/session-storage.js +39 -3
  96. package/dist/core/session-storage.js.map +1 -1
  97. package/dist/core/subagent-manager.d.ts.map +1 -1
  98. package/dist/core/subagent-manager.js +9 -1
  99. package/dist/core/subagent-manager.js.map +1 -1
  100. package/dist/core/subagent-manager.test.js +4 -3
  101. package/dist/core/subagent-manager.test.js.map +1 -1
  102. package/dist/interactive.d.ts.map +1 -1
  103. package/dist/interactive.js +17 -3
  104. package/dist/interactive.js.map +1 -1
  105. package/dist/modes/acp-mode.d.ts +82 -0
  106. package/dist/modes/acp-mode.d.ts.map +1 -0
  107. package/dist/modes/acp-mode.js +802 -0
  108. package/dist/modes/acp-mode.js.map +1 -0
  109. package/dist/modes/acp-mode.test.d.ts +2 -0
  110. package/dist/modes/acp-mode.test.d.ts.map +1 -0
  111. package/dist/modes/acp-mode.test.js +611 -0
  112. package/dist/modes/acp-mode.test.js.map +1 -0
  113. package/dist/modes/index.d.ts +1 -0
  114. package/dist/modes/index.d.ts.map +1 -1
  115. package/dist/modes/index.js +1 -0
  116. package/dist/modes/index.js.map +1 -1
  117. package/dist/session.d.ts +24 -0
  118. package/dist/session.d.ts.map +1 -1
  119. package/dist/session.js +32 -0
  120. package/dist/session.js.map +1 -1
  121. package/dist/tools/web-fetch.d.ts +1 -1
  122. package/dist/types.d.ts +7 -0
  123. package/dist/types.d.ts.map +1 -1
  124. package/package.json +4 -4
@@ -0,0 +1,802 @@
1
+ /**
2
+ * ACP mode: expose GG Coder as an Agent Client Protocol agent over stdio.
3
+ *
4
+ * This is the integration surface for ACP clients (Zed, pew2, any editor that
5
+ * speaks the protocol). It is deliberately a sibling of `rpc-mode.ts` — same
6
+ * shape, same `AgentSession`, same NDJSON-on-stdio transport — but the frames
7
+ * are the standard protocol instead of ggcoder's bespoke one, so a client
8
+ * written against the spec works with no ggcoder-specific code.
9
+ *
10
+ * Spec: https://agentclientprotocol.com/protocol/overview
11
+ *
12
+ * Scope: `initialize`, `session/new`, `session/prompt`, `session/cancel`,
13
+ * `session/list`, `session/load` and `session/set_config_option`. Everything
14
+ * advertised in `agentCapabilities` is implemented, because a client must be
15
+ * able to trust that list — advertising a method that then errors is worse
16
+ * than advertising nothing.
17
+ *
18
+ * stdout carries protocol frames ONLY. Anything diagnostic goes to stderr or
19
+ * the log file; a stray `console.log` anywhere in the process corrupts the
20
+ * stream and the client disconnects.
21
+ */
22
+ import readline from "node:readline";
23
+ import { isAbortError } from "@kenkaiiii/gg-agent";
24
+ import { getAllModels, getMaxThinkingLevel, getModel } from "@kenkaiiii/gg-core";
25
+ import { AgentSession } from "../core/agent-session.js";
26
+ import { findSessionById, listAllSessions, listSessionSummaries } from "../session.js";
27
+ import { formatUserError } from "../utils/error-handler.js";
28
+ import { closeLogger } from "../core/logger.js";
29
+ /** The ACP major version this mode implements. Bumped only for breaking changes. */
30
+ export const ACP_PROTOCOL_VERSION = 1;
31
+ /** JSON-RPC 2.0 reserved codes. ACP adds no codes of its own. */
32
+ const RPC_PARSE_ERROR = -32700;
33
+ const RPC_INVALID_REQUEST = -32600;
34
+ const RPC_METHOD_NOT_FOUND = -32601;
35
+ const RPC_INVALID_PARAMS = -32602;
36
+ const RPC_INTERNAL_ERROR = -32603;
37
+ /**
38
+ * The client asked for something impossible, as opposed to the agent failing.
39
+ *
40
+ * Typed rather than sniffed from the message text, so that a client can tell a
41
+ * bug in its own sequencing from an agent that fell over — and so that a later
42
+ * reworded message cannot silently change the code on the wire.
43
+ */
44
+ class InvalidParams extends Error {
45
+ }
46
+ // ── Tool mapping ───────────────────────────────────────────
47
+ /**
48
+ * ggcoder tool name → ACP `ToolKind`.
49
+ *
50
+ * Purely cosmetic on the client (icon + progress treatment), so an unmapped
51
+ * tool falls back to `other` rather than failing. Keep in step with
52
+ * `src/tools/index.ts` as tools are added.
53
+ */
54
+ const TOOL_KINDS = {
55
+ read: "read",
56
+ ls: "read",
57
+ find: "read",
58
+ grep: "search",
59
+ code_search: "search",
60
+ web_search: "search",
61
+ edit: "edit",
62
+ write: "edit",
63
+ bash: "execute",
64
+ screenshot: "execute",
65
+ web_fetch: "fetch",
66
+ source_path: "fetch",
67
+ subagent: "think",
68
+ spawn_agent: "think",
69
+ enter_plan: "switch_mode",
70
+ exit_plan: "switch_mode",
71
+ };
72
+ function toolKind(name) {
73
+ return TOOL_KINDS[name] ?? "other";
74
+ }
75
+ /**
76
+ * A one-line tool title for the client's activity list.
77
+ *
78
+ * The first string-ish argument is nearly always the interesting one (a path, a
79
+ * command, a query), and it is truncated because this renders on a phone.
80
+ */
81
+ function toolTitle(name, args) {
82
+ for (const value of Object.values(args)) {
83
+ if (typeof value !== "string" || value.length === 0)
84
+ continue;
85
+ const flat = value.replace(/\s+/g, " ").trim();
86
+ const clipped = flat.length > 80 ? `${flat.slice(0, 79)}…` : flat;
87
+ return `${name}(${clipped})`;
88
+ }
89
+ return name;
90
+ }
91
+ /**
92
+ * Why the current prompt turn ended.
93
+ *
94
+ * ACP has a closed set; ggcoder's `turn_end` carries whatever the provider
95
+ * said. Only the reasons that map cleanly are translated — everything else is
96
+ * a normal completion, which is what the client renders anyway.
97
+ */
98
+ function stopReasonFor(truncation, hitMaxTurns) {
99
+ if (hitMaxTurns)
100
+ return "max_turn_requests";
101
+ if (truncation === "max_tokens")
102
+ return "max_tokens";
103
+ if (truncation === "refusal")
104
+ return "refusal";
105
+ return "end_turn";
106
+ }
107
+ // ── Config options ─────────────────────────────────────
108
+ /** The selectors this agent exposes. Ids are part of the wire contract. */
109
+ export const MODEL_CONFIG_ID = "model";
110
+ export const THINKING_CONFIG_ID = "thinking";
111
+ export const MODE_CONFIG_ID = "mode";
112
+ /** ACP session modes, mapped onto GG's plan mode. */
113
+ const MODE_DEFAULT = "default";
114
+ const MODE_PLAN = "plan";
115
+ /** Thinking off is a real choice, so it needs a value id of its own. */
116
+ const THINKING_OFF = "off";
117
+ const THINKING_LADDER = [
118
+ "low",
119
+ "medium",
120
+ "high",
121
+ "xhigh",
122
+ "max",
123
+ "ultra",
124
+ ];
125
+ /**
126
+ * Thinking levels this model actually honours, hardest last.
127
+ *
128
+ * Offering levels above the model's ceiling would let a phone pick a setting
129
+ * that silently degrades to something else — a control that lies.
130
+ */
131
+ function thinkingOptionsFor(modelId) {
132
+ const ceiling = getMaxThinkingLevel(modelId);
133
+ const top = THINKING_LADDER.indexOf(ceiling);
134
+ const levels = THINKING_LADDER.slice(0, top < 0 ? THINKING_LADDER.length : top + 1);
135
+ return [
136
+ { value: THINKING_OFF, name: "Off", description: "No extended reasoning" },
137
+ ...levels.map((level) => ({ value: level, name: level })),
138
+ ];
139
+ }
140
+ /**
141
+ * The selectors a client renders for a session, with their live values.
142
+ *
143
+ * Model and thinking come from ggcoder's own registry rather than anything
144
+ * pew2- or Zed-specific, which is what lets a phone show GG Coder's real model
145
+ * list without either side hard-coding the other's.
146
+ */
147
+ function configOptionsFor(session) {
148
+ const { model } = session.getState();
149
+ const thinking = session.getThinkingLevel();
150
+ return [
151
+ {
152
+ id: MODEL_CONFIG_ID,
153
+ name: "Model",
154
+ category: "model",
155
+ type: "select",
156
+ currentValue: model,
157
+ options: getAllModels().map((entry) => ({
158
+ value: entry.id,
159
+ name: entry.name,
160
+ description: entry.provider,
161
+ })),
162
+ },
163
+ {
164
+ id: THINKING_CONFIG_ID,
165
+ name: "Thinking",
166
+ description: "Extended reasoning effort",
167
+ category: "thought_level",
168
+ type: "select",
169
+ // A model that cannot think reports `off`, which is the truth rather than
170
+ // a level it would ignore.
171
+ currentValue: getModel(model)?.supportsThinking ? (thinking ?? THINKING_OFF) : THINKING_OFF,
172
+ options: thinkingOptionsFor(model),
173
+ },
174
+ {
175
+ id: MODE_CONFIG_ID,
176
+ name: "Mode",
177
+ description: "Plan mode is read-only research until a plan is approved",
178
+ category: "mode",
179
+ type: "select",
180
+ currentValue: session.getPlanMode() ? MODE_PLAN : MODE_DEFAULT,
181
+ options: [
182
+ { value: MODE_DEFAULT, name: "Default", description: "Full tool access" },
183
+ { value: MODE_PLAN, name: "Plan", description: "Read-only until exit_plan" },
184
+ ],
185
+ },
186
+ ];
187
+ }
188
+ /** The `modes` block ACP clients like Zed read from session/new and session/load. */
189
+ function sessionModes(session) {
190
+ return {
191
+ currentModeId: session.getPlanMode() ? MODE_PLAN : MODE_DEFAULT,
192
+ availableModes: [
193
+ { id: MODE_DEFAULT, name: "Default", description: "Full tool access" },
194
+ { id: MODE_PLAN, name: "Plan", description: "Read-only research until a plan is approved" },
195
+ ],
196
+ };
197
+ }
198
+ // ── History replay ───────────────────────────────────
199
+ /** Flatten a persisted message's content to the text a client would show. */
200
+ function messageText(content) {
201
+ if (typeof content === "string")
202
+ return content;
203
+ if (!Array.isArray(content))
204
+ return "";
205
+ return content
206
+ .filter((block) => block?.type === "text")
207
+ .map((block) => block.text ?? "")
208
+ .join("");
209
+ }
210
+ /**
211
+ * Turn a restored conversation into the `session/update` stream a client needs
212
+ * to draw it.
213
+ *
214
+ * ACP has no "here is the transcript" response: a loaded session is replayed as
215
+ * the same notifications a live turn produces, so the client needs no second
216
+ * rendering path. Thinking is deliberately NOT replayed — it is transient by
217
+ * design, and a wall of stale reasoning above a resumed conversation buries the
218
+ * thing the user came back for.
219
+ */
220
+ export function historyUpdates(messages) {
221
+ const updates = [];
222
+ for (const message of messages) {
223
+ if (message.role === "system")
224
+ continue;
225
+ if (message.role === "user") {
226
+ const text = messageText(message.content);
227
+ if (text) {
228
+ updates.push({
229
+ sessionUpdate: "user_message_chunk",
230
+ content: { type: "text", text },
231
+ });
232
+ }
233
+ continue;
234
+ }
235
+ if (message.role === "assistant") {
236
+ const text = messageText(message.content);
237
+ if (text) {
238
+ updates.push({
239
+ sessionUpdate: "agent_message_chunk",
240
+ content: { type: "text", text },
241
+ });
242
+ }
243
+ if (Array.isArray(message.content)) {
244
+ for (const block of message.content) {
245
+ if (block.type !== "tool_call")
246
+ continue;
247
+ const call = block;
248
+ const args = call.args ?? {};
249
+ updates.push({
250
+ sessionUpdate: "tool_call",
251
+ toolCallId: call.id,
252
+ title: toolTitle(call.name, args),
253
+ name: call.name,
254
+ kind: toolKind(call.name),
255
+ // History is settled: a replayed call that still said `in_progress`
256
+ // would leave a spinner running forever on a resumed session.
257
+ status: "completed",
258
+ rawInput: args,
259
+ });
260
+ }
261
+ }
262
+ continue;
263
+ }
264
+ for (const result of message.content) {
265
+ const entry = result;
266
+ updates.push({
267
+ sessionUpdate: "tool_call_update",
268
+ toolCallId: entry.toolCallId,
269
+ status: entry.isError ? "failed" : "completed",
270
+ content: [
271
+ {
272
+ type: "content",
273
+ content: { type: "text", text: messageText(entry.content) },
274
+ },
275
+ ],
276
+ });
277
+ }
278
+ }
279
+ return updates;
280
+ }
281
+ // ── Mode ───────────────────────────────────────────────────
282
+ /**
283
+ * Serve ACP on stdio until the input stream ends.
284
+ *
285
+ * Resolves when the client disconnects; the caller owns process exit.
286
+ */
287
+ export async function runAcpMode(options) {
288
+ const input = options.input ?? process.stdin;
289
+ const output = options.output ?? process.stdout;
290
+ function write(frame) {
291
+ output.write(`${JSON.stringify(frame)}\n`);
292
+ }
293
+ function respond(id, result) {
294
+ write({ jsonrpc: "2.0", id, result });
295
+ }
296
+ function fail(id, code, message) {
297
+ write({ jsonrpc: "2.0", id, error: { code, message } });
298
+ }
299
+ /**
300
+ * The single session this process serves.
301
+ *
302
+ * ACP allows many per connection, but an ACP client spawns one agent process
303
+ * per session anyway, and `AgentSession` owns process-wide state (cwd, tool
304
+ * registry, session file). One-per-process is the honest model; a second
305
+ * `session/new` replaces the first rather than pretending to multiplex.
306
+ */
307
+ let session = null;
308
+ let sessionId = "";
309
+ let abort = new AbortController();
310
+ /** Set while a `session/prompt` is in flight, so a second one is rejected. */
311
+ let running = false;
312
+ let cancelled = false;
313
+ let truncation;
314
+ let hitMaxTurns = false;
315
+ /** Detaches every event listener when the session is replaced or disposed. */
316
+ let unwire = [];
317
+ function notifyUpdate(update) {
318
+ write({
319
+ jsonrpc: "2.0",
320
+ method: "session/update",
321
+ params: { sessionId, update },
322
+ });
323
+ }
324
+ /**
325
+ * Tell the client the session mode changed outside a request it made — the
326
+ * model itself can enter/exit plan mode mid-run via the enter_plan/exit_plan
327
+ * tools, and a picker that only tracks its own changes would lie.
328
+ */
329
+ function notifyModeChange(modeId) {
330
+ if (!session)
331
+ return;
332
+ notifyUpdate({ sessionUpdate: "current_mode_update", currentModeId: modeId });
333
+ notifyUpdate({
334
+ sessionUpdate: "config_option_update",
335
+ configOptions: configOptionsFor(session),
336
+ });
337
+ }
338
+ /**
339
+ * Bridge ggcoder's event bus onto `session/update` notifications.
340
+ *
341
+ * Every handler is synchronous and writes immediately, which is what keeps
342
+ * updates ordered ahead of the `session/prompt` response — a client that sees
343
+ * the result before the chunks renders an empty turn.
344
+ */
345
+ function wire(target) {
346
+ const bus = target.eventBus;
347
+ unwire = [
348
+ bus.on("text_delta", ({ text }) => {
349
+ notifyUpdate({
350
+ sessionUpdate: "agent_message_chunk",
351
+ content: { type: "text", text },
352
+ });
353
+ }),
354
+ bus.on("thinking_delta", ({ text }) => {
355
+ notifyUpdate({
356
+ sessionUpdate: "agent_thought_chunk",
357
+ content: { type: "text", text },
358
+ });
359
+ }),
360
+ bus.on("tool_call_start", ({ toolCallId, name, args }) => {
361
+ notifyUpdate({
362
+ sessionUpdate: "tool_call",
363
+ toolCallId,
364
+ title: toolTitle(name, args),
365
+ name,
366
+ kind: toolKind(name),
367
+ status: "in_progress",
368
+ rawInput: args,
369
+ });
370
+ }),
371
+ // Mid-flight tool progress. The payload is tool-defined, so it rides in
372
+ // `rawOutput` rather than being invented into content the client would
373
+ // then render as if it were final.
374
+ bus.on("tool_call_update", ({ toolCallId, update }) => {
375
+ notifyUpdate({
376
+ sessionUpdate: "tool_call_update",
377
+ toolCallId,
378
+ status: "in_progress",
379
+ rawOutput: update,
380
+ });
381
+ }),
382
+ bus.on("tool_call_end", ({ toolCallId, result, isError }) => {
383
+ notifyUpdate({
384
+ sessionUpdate: "tool_call_update",
385
+ toolCallId,
386
+ status: isError ? "failed" : "completed",
387
+ content: [{ type: "content", content: { type: "text", text: result } }],
388
+ });
389
+ }),
390
+ // Turn-level outcomes are remembered rather than sent: ACP reports them
391
+ // once, as the `stopReason` of the prompt response.
392
+ bus.on("truncated", ({ reason }) => {
393
+ truncation = reason;
394
+ }),
395
+ bus.on("max_turns", () => {
396
+ hitMaxTurns = true;
397
+ }),
398
+ ];
399
+ }
400
+ function unwireAll() {
401
+ for (const off of unwire)
402
+ off();
403
+ unwire = [];
404
+ }
405
+ async function disposeSession() {
406
+ if (!session)
407
+ return;
408
+ unwireAll();
409
+ const previous = session;
410
+ session = null;
411
+ sessionId = "";
412
+ await previous.dispose();
413
+ }
414
+ const createSession = options.createSession ??
415
+ ((signal) => {
416
+ // Self-reference is safe: the callbacks only run once the agent loop is
417
+ // executing tools, long after the constructor returns.
418
+ const created = new AgentSession({
419
+ provider: options.provider,
420
+ model: options.model,
421
+ cwd: options.cwd,
422
+ baseUrl: options.baseUrl,
423
+ systemPrompt: options.systemPrompt,
424
+ thinkingLevel: options.thinkingLevel,
425
+ // MCP connect (spawning stdio servers, HTTP handshakes) takes seconds
426
+ // and would otherwise sit on the critical path of session/new and
427
+ // session/load. The desktop sidecar already ships this path: the tool
428
+ // catalog is seeded from the disk cache so tools are visible
429
+ // immediately, and live connections promote in the background. A phone
430
+ // client gets its session in milliseconds and the same tools a moment
431
+ // later.
432
+ backgroundMcpConnect: true,
433
+ // Plan mode. Supplying these callbacks is what registers the
434
+ // enter_plan/exit_plan tools at all — without them the mode exists but
435
+ // the model cannot move between states. GG Coder runs without
436
+ // approvals, so a submitted plan is auto-approved, the [DONE:n]
437
+ // contract is baked in so progress markers work as on the desktop, and
438
+ // the client is told about every mode change.
439
+ onEnterPlan: async () => {
440
+ await created.setPlanMode(true);
441
+ notifyModeChange(MODE_PLAN);
442
+ },
443
+ onExitPlan: async (planPath) => {
444
+ await created.setPlanMode(false);
445
+ await created.setApprovedPlan(planPath);
446
+ notifyModeChange(MODE_DEFAULT);
447
+ return "Plan approved. Proceed with implementation, marking each completed step with [DONE:n].";
448
+ },
449
+ signal,
450
+ });
451
+ return created;
452
+ });
453
+ // ── Method handlers ──────────────────────────────────────
454
+ function handleInitialize() {
455
+ return {
456
+ protocolVersion: ACP_PROTOCOL_VERSION,
457
+ agentCapabilities: {
458
+ loadSession: true,
459
+ promptCapabilities: { image: false, audio: false, embeddedContext: false },
460
+ mcpCapabilities: { http: false, sse: false, acp: false },
461
+ // `{}` is how ACP says "supported" for a capability with no options of
462
+ // its own. Omitting the key means unsupported, so this is not cosmetic.
463
+ sessionCapabilities: { list: {}, resume: {} },
464
+ },
465
+ authMethods: [],
466
+ agentInfo: { name: "ggcoder", title: "GG Coder", version: options.version },
467
+ };
468
+ }
469
+ /**
470
+ * Start a session, optionally replaying a stored one into it.
471
+ *
472
+ * `session/new` and `session/load` differ only in whether history is
473
+ * restored first, so they share this path rather than drifting apart.
474
+ */
475
+ async function startSession(restorePath) {
476
+ // Replacing an in-flight session would strand the prompt that is running
477
+ // on it, so the old one is stopped first, deliberately and visibly.
478
+ await disposeSession();
479
+ abort = new AbortController();
480
+ const created = createSession(abort.signal);
481
+ await created.initialize();
482
+ if (restorePath)
483
+ await created.loadSession(restorePath);
484
+ session = created;
485
+ sessionId = created.getState().sessionId;
486
+ wire(created);
487
+ return created;
488
+ }
489
+ async function handleNewSession() {
490
+ const created = await startSession();
491
+ return { sessionId, configOptions: configOptionsFor(created), modes: sessionModes(created) };
492
+ }
493
+ /** The directory a request is about, defaulting to the one we were started in. */
494
+ function requestCwd(params) {
495
+ const cwd = params?.cwd;
496
+ return typeof cwd === "string" && cwd ? cwd : options.cwd;
497
+ }
498
+ /**
499
+ * Stored sessions, newest first.
500
+ *
501
+ * This is the answer to "see everything that was on GG Coder": the phone asks
502
+ * the agent, and the agent reads the same `~/.gg/sessions` files the desktop
503
+ * browses — no separate index to fall out of step.
504
+ *
505
+ * ACP makes `cwd` nullable here on purpose. Omitted means EVERY project, not
506
+ * the agent's own directory: a remote client is asking what you have been
507
+ * working on, and that spans checkouts. Each entry carries its own `cwd` so
508
+ * the client can group by project.
509
+ */
510
+ async function handleListSessions(params) {
511
+ const scope = params?.cwd;
512
+ const stored = typeof scope === "string" && scope
513
+ ? await listSessionSummaries(scope)
514
+ : await listAllSessions();
515
+ return {
516
+ sessions: stored
517
+ // An empty session has nothing to resume and nothing to title; listing
518
+ // it would fill the phone with identical blank rows.
519
+ .filter((entry) => entry.hasMessages)
520
+ .map((entry) => ({
521
+ sessionId: entry.id,
522
+ cwd: entry.cwd,
523
+ // Captured during the listing pass, so titling the whole machine's
524
+ // history costs no extra reads.
525
+ title: entry.preview ?? null,
526
+ updatedAt: entry.lastActivity,
527
+ })),
528
+ };
529
+ }
530
+ /**
531
+ * Resume a stored session and replay it to the client.
532
+ *
533
+ * The transcript is streamed as `session/update` notifications BEFORE this
534
+ * resolves, which is what the protocol requires and what lets a client draw a
535
+ * resumed conversation with its normal live-turn rendering.
536
+ */
537
+ async function handleLoadSession(params) {
538
+ const requested = params?.sessionId;
539
+ if (typeof requested !== "string" || !requested) {
540
+ throw new InvalidParams("session/load requires a sessionId.");
541
+ }
542
+ // Searched by id across every project directory, not just `cwd`: a client
543
+ // lists sessions from wherever it probed and reopens them against the
544
+ // directory the session belongs to, so those two rarely match.
545
+ const sessionPath = await findSessionById(requested, requestCwd(params));
546
+ if (!sessionPath)
547
+ throw new InvalidParams(`Unknown session '${requested}'.`);
548
+ const restored = await startSession(sessionPath);
549
+ // The id the client asked for is the id it keeps using; `loadSession` may
550
+ // adopt a different internal one, and answering with that would leave the
551
+ // client addressing a session it never heard of.
552
+ sessionId = requested;
553
+ for (const update of historyUpdates(restored.getMessages()))
554
+ notifyUpdate(update);
555
+ return { configOptions: configOptionsFor(restored), modes: sessionModes(restored) };
556
+ }
557
+ /**
558
+ * Switch session mode (ACP `session/set_mode`; Zed's mode picker uses this,
559
+ * pew2 routes it through session/set_config_option with configId "mode").
560
+ */
561
+ async function handleSetMode(params) {
562
+ if (!session)
563
+ throw new InvalidParams("No session. Call session/new first.");
564
+ const modeId = params?.modeId;
565
+ if (modeId !== MODE_DEFAULT && modeId !== MODE_PLAN) {
566
+ throw new InvalidParams(`Unknown mode '${String(modeId)}'.`);
567
+ }
568
+ const plan = modeId === MODE_PLAN;
569
+ if (session.getPlanMode() !== plan) {
570
+ await session.setPlanMode(plan);
571
+ notifyModeChange(modeId);
572
+ }
573
+ return {};
574
+ }
575
+ /**
576
+ * Apply a selector change from the client.
577
+ *
578
+ * Replies with the COMPLETE option set rather than the one that changed:
579
+ * switching model can move the thinking ceiling, and a client that only
580
+ * patched the field it sent would keep offering levels the new model ignores.
581
+ */
582
+ async function handleSetConfigOption(params) {
583
+ if (!session)
584
+ throw new InvalidParams("No session. Call session/new first.");
585
+ const { configId, value } = (params ?? {});
586
+ if (typeof configId !== "string" || typeof value !== "string") {
587
+ throw new InvalidParams("session/set_config_option requires a configId and a string value.");
588
+ }
589
+ if (configId === MODEL_CONFIG_ID) {
590
+ const target = getModel(value);
591
+ if (!target)
592
+ throw new InvalidParams(`Unknown model '${value}'.`);
593
+ await session.switchModel(target.provider, target.id);
594
+ // Thinking levels are per-model, so a level the previous model allowed can
595
+ // be above the new one's ceiling. Clamping here keeps the session's actual
596
+ // effort equal to what the client is about to be told it is.
597
+ const current = session.getThinkingLevel();
598
+ if (current) {
599
+ const allowed = thinkingOptionsFor(target.id).map((option) => option.value);
600
+ if (!allowed.includes(current))
601
+ session.setThinkingLevel(getMaxThinkingLevel(target.id));
602
+ }
603
+ }
604
+ else if (configId === MODE_CONFIG_ID) {
605
+ // Share the dedicated handler so both entry points behave identically.
606
+ await handleSetMode({
607
+ sessionId: params?.sessionId,
608
+ modeId: value,
609
+ });
610
+ }
611
+ else if (configId === THINKING_CONFIG_ID) {
612
+ if (value === THINKING_OFF) {
613
+ session.setThinkingLevel(undefined);
614
+ }
615
+ else {
616
+ const { model } = session.getState();
617
+ const allowed = thinkingOptionsFor(model).map((option) => option.value);
618
+ if (!allowed.includes(value)) {
619
+ throw new InvalidParams(`Model '${model}' does not support thinking level '${value}'.`);
620
+ }
621
+ session.setThinkingLevel(value);
622
+ }
623
+ }
624
+ else {
625
+ throw new InvalidParams(`Unknown config option '${configId}'.`);
626
+ }
627
+ return { configOptions: configOptionsFor(session) };
628
+ }
629
+ /** Flatten ACP prompt blocks to the plain text `AgentSession` accepts. */
630
+ function promptText(params) {
631
+ const blocks = params?.prompt;
632
+ if (!Array.isArray(blocks))
633
+ return "";
634
+ return blocks
635
+ .map((block) => {
636
+ const entry = block;
637
+ return entry?.type === "text" && typeof entry.text === "string" ? entry.text : "";
638
+ })
639
+ .filter(Boolean)
640
+ .join("\n")
641
+ .trim();
642
+ }
643
+ async function handlePrompt(params) {
644
+ if (!session)
645
+ throw new InvalidParams("No session. Call session/new first.");
646
+ if (running)
647
+ throw new InvalidParams("A prompt is already running in this session.");
648
+ const text = promptText(params);
649
+ if (!text)
650
+ throw new InvalidParams("prompt must contain at least one non-empty text block.");
651
+ running = true;
652
+ cancelled = false;
653
+ truncation = undefined;
654
+ hitMaxTurns = false;
655
+ try {
656
+ await session.prompt(text);
657
+ }
658
+ catch (err) {
659
+ // A cancel aborts the loop, which surfaces here. The spec requires the
660
+ // turn to still resolve with `cancelled` rather than reject — the client
661
+ // is confirming its own cancel, not being told something went wrong.
662
+ if (cancelled || isAbortError(err))
663
+ return { stopReason: "cancelled" };
664
+ throw err;
665
+ }
666
+ finally {
667
+ running = false;
668
+ }
669
+ return { stopReason: cancelled ? "cancelled" : stopReasonFor(truncation, hitMaxTurns) };
670
+ }
671
+ function handleCancel() {
672
+ if (!running || !session)
673
+ return;
674
+ cancelled = true;
675
+ abort.abort();
676
+ // `AgentSession` keeps the construction signal for future turns. Re-arm it
677
+ // immediately after aborting this turn, or every later prompt sees an
678
+ // already-aborted signal and silently completes with no output.
679
+ abort = new AbortController();
680
+ session.setSignal(abort.signal);
681
+ }
682
+ async function dispatch(method, params) {
683
+ switch (method) {
684
+ case "initialize":
685
+ return handleInitialize();
686
+ case "session/new":
687
+ return handleNewSession();
688
+ case "session/prompt":
689
+ return handlePrompt(params);
690
+ case "session/list":
691
+ return handleListSessions(params);
692
+ case "session/load":
693
+ return handleLoadSession(params);
694
+ case "session/set_config_option":
695
+ return handleSetConfigOption(params);
696
+ case "session/set_mode":
697
+ return handleSetMode(params);
698
+ default:
699
+ return undefined;
700
+ }
701
+ }
702
+ // ── Read loop ────────────────────────────────────────────
703
+ const rl = readline.createInterface({ input, terminal: false });
704
+ /**
705
+ * Requests still being served.
706
+ *
707
+ * Requests are dispatched WITHOUT blocking the read loop, because
708
+ * `session/prompt` runs for as long as the agent does — awaiting it here
709
+ * would mean `session/cancel` is not read until the turn it cancels has
710
+ * already finished. That is a deadlock, not a slow path.
711
+ */
712
+ const inFlight = new Set();
713
+ function serve(id, method, params) {
714
+ const task = (async () => {
715
+ try {
716
+ const result = await dispatch(method, params);
717
+ if (result === undefined) {
718
+ fail(id, RPC_METHOD_NOT_FOUND, `Unsupported method: ${method}`);
719
+ return;
720
+ }
721
+ respond(id, result);
722
+ }
723
+ catch (err) {
724
+ const message = err instanceof Error ? err.message : String(err);
725
+ const code = err instanceof InvalidParams ? RPC_INVALID_PARAMS : RPC_INTERNAL_ERROR;
726
+ fail(id, code, message);
727
+ }
728
+ })();
729
+ inFlight.add(task);
730
+ void task.finally(() => inFlight.delete(task));
731
+ }
732
+ try {
733
+ for await (const line of rl) {
734
+ if (!line.trim())
735
+ continue;
736
+ let frame;
737
+ try {
738
+ frame = JSON.parse(line);
739
+ }
740
+ catch {
741
+ // No id to answer against, so this is the one case that uses a null id,
742
+ // exactly as JSON-RPC 2.0 prescribes for unparseable input.
743
+ write({
744
+ jsonrpc: "2.0",
745
+ id: null,
746
+ error: { code: RPC_PARSE_ERROR, message: "Invalid JSON" },
747
+ });
748
+ continue;
749
+ }
750
+ const { id, method } = frame;
751
+ if (typeof method !== "string") {
752
+ if (id !== undefined)
753
+ fail(id, RPC_INVALID_REQUEST, "Missing 'method'");
754
+ continue;
755
+ }
756
+ // Notifications: no id, no response, ever — answering one is a protocol
757
+ // violation that some clients treat as a fatal desync.
758
+ if (id === undefined) {
759
+ if (method === "session/cancel")
760
+ handleCancel();
761
+ continue;
762
+ }
763
+ serve(id, method, frame.params);
764
+ }
765
+ }
766
+ finally {
767
+ rl.close();
768
+ // The client is gone, so a turn still running has nowhere to report to.
769
+ // Cancelling first is what stops the wait below from being unbounded.
770
+ handleCancel();
771
+ // A disconnect mid-turn must still not tear the session out from under the
772
+ // run that is writing to it.
773
+ await Promise.allSettled([...inFlight]);
774
+ await disposeSession().catch(() => { });
775
+ }
776
+ }
777
+ /**
778
+ * CLI entry point: serve ACP on the real stdio, then exit.
779
+ *
780
+ * Split from {@link runAcpMode} so the protocol can be tested without the
781
+ * process-level side effects (signal handlers, logger teardown, exit codes).
782
+ */
783
+ export async function runAcpModeCli(options) {
784
+ const onSigint = () => {
785
+ // An ACP agent is driven entirely by its client; Ctrl-C is the user asking
786
+ // the process to end, not the current turn to stop.
787
+ process.exit(130);
788
+ };
789
+ process.on("SIGINT", onSigint);
790
+ try {
791
+ await runAcpMode(options);
792
+ }
793
+ catch (err) {
794
+ process.stderr.write(`${formatUserError(err)}\n`);
795
+ process.exitCode = 1;
796
+ }
797
+ finally {
798
+ process.removeListener("SIGINT", onSigint);
799
+ closeLogger();
800
+ }
801
+ }
802
+ //# sourceMappingURL=acp-mode.js.map