@kenkaiiii/ggcoder 5.33.0 → 5.34.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/dist/modes/acp-mode.js
CHANGED
|
@@ -10,16 +10,20 @@
|
|
|
10
10
|
* Spec: https://agentclientprotocol.com/protocol/overview
|
|
11
11
|
*
|
|
12
12
|
* Scope: `initialize`, `session/new`, `session/prompt`, `session/cancel`,
|
|
13
|
-
* `session/list`, `session/load`
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* `session/list`, `session/load`, `session/resume`, `session/close`,
|
|
14
|
+
* `session/delete`, `session/set_mode` and `session/set_config_option`.
|
|
15
|
+
* Everything advertised in `agentCapabilities` is implemented, because a client
|
|
16
|
+
* must be able to trust that list — advertising a method that then errors is
|
|
17
|
+
* worse than advertising nothing.
|
|
17
18
|
*
|
|
18
19
|
* stdout carries protocol frames ONLY. Anything diagnostic goes to stderr or
|
|
19
20
|
* the log file; a stray `console.log` anywhere in the process corrupts the
|
|
20
21
|
* stream and the client disconnects.
|
|
21
22
|
*/
|
|
22
23
|
import readline from "node:readline";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { readFileSync, statSync } from "node:fs";
|
|
26
|
+
import { rm } from "node:fs/promises";
|
|
23
27
|
import { isAbortError } from "@kenkaiiii/gg-agent";
|
|
24
28
|
import { getAllModels, getMaxThinkingLevel, getModel } from "@kenkaiiii/gg-core";
|
|
25
29
|
import { AgentSession } from "../core/agent-session.js";
|
|
@@ -27,6 +31,9 @@ import { PROMPT_COMMANDS } from "../core/prompt-commands.js";
|
|
|
27
31
|
import { loadCustomCommands } from "../core/custom-commands.js";
|
|
28
32
|
import { findSessionById, listAllSessions, listSessionSummaries, loadSessionCheckpointChain, } from "../session.js";
|
|
29
33
|
import { getHistoryMessageVisibility, reconstructCheckpointHistory, restoreUserRow, } from "../core/session-history.js";
|
|
34
|
+
import { findUserSessionPrompt } from "../core/session-preview.js";
|
|
35
|
+
import { sessionGroupPaths } from "../core/session-storage.js";
|
|
36
|
+
import { extractPlanSteps, findCompletedMarkers, markStepsCompleted, rebasePlanSteps, } from "../utils/plan-steps.js";
|
|
30
37
|
import { formatUserError } from "../utils/error-handler.js";
|
|
31
38
|
import { closeLogger } from "../core/logger.js";
|
|
32
39
|
/** The ACP major version this mode implements. Bumped only for breaking changes. */
|
|
@@ -91,6 +98,114 @@ function toolTitle(name, args) {
|
|
|
91
98
|
}
|
|
92
99
|
return name;
|
|
93
100
|
}
|
|
101
|
+
// ── Tool locations ─────────────────────────────────────────
|
|
102
|
+
/**
|
|
103
|
+
* Argument names that hold the path a tool works on, most specific first.
|
|
104
|
+
*
|
|
105
|
+
* Deliberately a small allowlist rather than "any string that looks like a
|
|
106
|
+
* path": `bash`'s command and `web_fetch`'s url would both pass a heuristic and
|
|
107
|
+
* both would send the client's editor somewhere that does not exist.
|
|
108
|
+
*/
|
|
109
|
+
const PATH_ARG_KEYS = ["file_path", "path", "out_path"];
|
|
110
|
+
/**
|
|
111
|
+
* The file a tool call touches, which is what drives "follow the agent" in a
|
|
112
|
+
* client: the editor jumps to whatever GG is reading or editing right now.
|
|
113
|
+
*
|
|
114
|
+
* Paths are resolved to absolute against the session cwd, because the client
|
|
115
|
+
* runs somewhere else entirely and cannot know what a relative path was
|
|
116
|
+
* relative to. A wrong location is worse than none, so anything unrecognised
|
|
117
|
+
* reports nothing.
|
|
118
|
+
*/
|
|
119
|
+
function toolLocations(args, cwd) {
|
|
120
|
+
for (const key of PATH_ARG_KEYS) {
|
|
121
|
+
const value = args[key];
|
|
122
|
+
if (typeof value !== "string" || !value)
|
|
123
|
+
continue;
|
|
124
|
+
const absolute = path.isAbsolute(value) ? value : path.resolve(cwd, value);
|
|
125
|
+
// `read`'s offset is a 1-based line, which is exactly what ACP wants for
|
|
126
|
+
// scrolling the client to the region being looked at.
|
|
127
|
+
const offset = args.offset;
|
|
128
|
+
return typeof offset === "number" && Number.isInteger(offset) && offset > 0
|
|
129
|
+
? [{ path: absolute, line: offset }]
|
|
130
|
+
: [{ path: absolute }];
|
|
131
|
+
}
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
// ── File diffs ─────────────────────────────────────────────
|
|
135
|
+
/** Tools whose whole purpose is changing a file's contents. */
|
|
136
|
+
const DIFF_TOOLS = new Set(["edit", "write"]);
|
|
137
|
+
/**
|
|
138
|
+
* Largest file we will snapshot to build a diff.
|
|
139
|
+
*
|
|
140
|
+
* The contents cross the wire twice (old and new) and are read on the event
|
|
141
|
+
* loop, so a generated bundle or lockfile would stall the turn and flood the
|
|
142
|
+
* client with a diff no human is going to read.
|
|
143
|
+
*/
|
|
144
|
+
const MAX_DIFF_BYTES = 256 * 1024;
|
|
145
|
+
/**
|
|
146
|
+
* Read a file for diffing, SYNCHRONOUSLY and on purpose.
|
|
147
|
+
*
|
|
148
|
+
* The "before" snapshot is taken inside the `tool_call_start` handler, and the
|
|
149
|
+
* tool it belongs to begins writing in the same tick. An async read would race
|
|
150
|
+
* that write and could capture the file as it is AFTER the edit, which renders
|
|
151
|
+
* in the client as a real change with an empty diff.
|
|
152
|
+
*/
|
|
153
|
+
function snapshotForDiff(filePath) {
|
|
154
|
+
let size;
|
|
155
|
+
try {
|
|
156
|
+
size = statSync(filePath).size;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Missing: `write` creating a new file. ACP represents that as a null
|
|
160
|
+
// `oldText`, which clients render as an all-additions diff.
|
|
161
|
+
return { text: null };
|
|
162
|
+
}
|
|
163
|
+
if (size > MAX_DIFF_BYTES)
|
|
164
|
+
return undefined;
|
|
165
|
+
try {
|
|
166
|
+
return { text: readFileSync(filePath, "utf8") };
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// ── Plans ──────────────────────────────────────────────────
|
|
173
|
+
/**
|
|
174
|
+
* GG's plan steps as ACP plan entries.
|
|
175
|
+
*
|
|
176
|
+
* ACP requires a priority per entry and GG's plans have no such concept, so
|
|
177
|
+
* every entry reports `medium` rather than inventing a ranking the user never
|
|
178
|
+
* expressed. The first unfinished step is reported `in_progress`: entries are
|
|
179
|
+
* worked in order, so this is what the agent is actually doing now, and it
|
|
180
|
+
* gives the client a live marker instead of a list that only ever flips from
|
|
181
|
+
* pending to completed.
|
|
182
|
+
*/
|
|
183
|
+
function planEntries(steps) {
|
|
184
|
+
let activeMarked = false;
|
|
185
|
+
return steps.map((step) => {
|
|
186
|
+
let status;
|
|
187
|
+
if (step.completed) {
|
|
188
|
+
status = "completed";
|
|
189
|
+
}
|
|
190
|
+
else if (activeMarked) {
|
|
191
|
+
status = "pending";
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
activeMarked = true;
|
|
195
|
+
status = "in_progress";
|
|
196
|
+
}
|
|
197
|
+
return { content: step.text, priority: "medium", status };
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/** Read a plan markdown file, or empty string when it has gone missing. */
|
|
201
|
+
function readPlanFile(planPath) {
|
|
202
|
+
try {
|
|
203
|
+
return readFileSync(planPath, "utf8");
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return "";
|
|
207
|
+
}
|
|
208
|
+
}
|
|
94
209
|
/**
|
|
95
210
|
* Why the current prompt turn ended.
|
|
96
211
|
*
|
|
@@ -286,9 +401,15 @@ function messageText(content) {
|
|
|
286
401
|
* rendering path. Thinking is deliberately NOT replayed — it is transient by
|
|
287
402
|
* design, and a wall of stale reasoning above a resumed conversation buries the
|
|
288
403
|
* thing the user came back for.
|
|
404
|
+
*
|
|
405
|
+
* Each replayed chunk carries a `messageId` so a client can group chunks into
|
|
406
|
+
* the messages they came from; ids are per-replay and positional, which is all
|
|
407
|
+
* the protocol needs of them.
|
|
289
408
|
*/
|
|
290
409
|
export function historyUpdates(messages) {
|
|
291
410
|
const updates = [];
|
|
411
|
+
let replayed = 0;
|
|
412
|
+
const nextMessageId = () => `hist-${++replayed}`;
|
|
292
413
|
for (const message of messages) {
|
|
293
414
|
if (getHistoryMessageVisibility(message) === "hidden")
|
|
294
415
|
continue;
|
|
@@ -299,6 +420,7 @@ export function historyUpdates(messages) {
|
|
|
299
420
|
if (restored.text) {
|
|
300
421
|
updates.push({
|
|
301
422
|
sessionUpdate: "user_message_chunk",
|
|
423
|
+
messageId: nextMessageId(),
|
|
302
424
|
content: { type: "text", text: restored.text },
|
|
303
425
|
});
|
|
304
426
|
}
|
|
@@ -309,6 +431,7 @@ export function historyUpdates(messages) {
|
|
|
309
431
|
if (text) {
|
|
310
432
|
updates.push({
|
|
311
433
|
sessionUpdate: "agent_message_chunk",
|
|
434
|
+
messageId: nextMessageId(),
|
|
312
435
|
content: { type: "text", text },
|
|
313
436
|
});
|
|
314
437
|
}
|
|
@@ -386,6 +509,24 @@ export async function runAcpMode(options) {
|
|
|
386
509
|
let hitMaxTurns = false;
|
|
387
510
|
/** Detaches every event listener when the session is replaced or disposed. */
|
|
388
511
|
let unwire = [];
|
|
512
|
+
/**
|
|
513
|
+
* Chunk grouping. A message runs until something interrupts it — a tool call
|
|
514
|
+
* or the end of the turn — so the id is minted lazily on the first chunk and
|
|
515
|
+
* dropped at those boundaries, which is exactly where the client should start
|
|
516
|
+
* a new bubble.
|
|
517
|
+
*/
|
|
518
|
+
let messageSeq = 0;
|
|
519
|
+
let currentMessageId = null;
|
|
520
|
+
/** Before-snapshots for in-flight `edit`/`write` calls, keyed by tool call. */
|
|
521
|
+
const diffSnapshots = new Map();
|
|
522
|
+
/** The approved plan being implemented, and how far through it the agent is. */
|
|
523
|
+
let planPath;
|
|
524
|
+
let planSteps = [];
|
|
525
|
+
const completedSteps = new Set();
|
|
526
|
+
/** This turn's assistant text, scanned for `[DONE:n]` markers. */
|
|
527
|
+
let turnText = "";
|
|
528
|
+
/** Whether this session has already announced a title to the client. */
|
|
529
|
+
let titleAnnounced = false;
|
|
389
530
|
function notifyUpdate(update) {
|
|
390
531
|
write({
|
|
391
532
|
jsonrpc: "2.0",
|
|
@@ -420,22 +561,102 @@ export async function runAcpMode(options) {
|
|
|
420
561
|
});
|
|
421
562
|
}
|
|
422
563
|
/**
|
|
423
|
-
*
|
|
564
|
+
* State for a session that was just created or restored, sent after the
|
|
424
565
|
* response that told the client the session exists.
|
|
425
566
|
*
|
|
426
567
|
* Same deferral (and same staleness guard) as {@link notifyAvailableCommands}:
|
|
427
568
|
* a notification addressed to a sessionId the client has not seen yet has
|
|
428
|
-
* nowhere to land. Without this a resumed conversation shows no usage
|
|
429
|
-
* its first reply, which is exactly when
|
|
569
|
+
* nowhere to land. Without this a resumed conversation shows no usage and no
|
|
570
|
+
* title until its first reply, which is exactly when they matter least.
|
|
430
571
|
*/
|
|
431
|
-
function
|
|
572
|
+
function announceSessionSoon(target) {
|
|
432
573
|
const forSession = sessionId;
|
|
433
574
|
setTimeout(() => {
|
|
434
575
|
if (session !== target || sessionId !== forSession)
|
|
435
576
|
return;
|
|
436
577
|
notifyUsage(target);
|
|
578
|
+
notifySessionInfo(target);
|
|
437
579
|
}, 0);
|
|
438
580
|
}
|
|
581
|
+
/**
|
|
582
|
+
* The id chunks of the current agent message share, minted on demand.
|
|
583
|
+
*/
|
|
584
|
+
function messageId() {
|
|
585
|
+
currentMessageId ??= `msg-${++messageSeq}`;
|
|
586
|
+
return currentMessageId;
|
|
587
|
+
}
|
|
588
|
+
/** Start a new message at the next chunk (tool call, or end of turn). */
|
|
589
|
+
function endMessage() {
|
|
590
|
+
currentMessageId = null;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Send the whole plan, which is what ACP requires: the client REPLACES its
|
|
594
|
+
* copy on every update rather than patching it, so a partial list would
|
|
595
|
+
* silently delete steps.
|
|
596
|
+
*/
|
|
597
|
+
function notifyPlan() {
|
|
598
|
+
if (planSteps.length === 0)
|
|
599
|
+
return;
|
|
600
|
+
notifyUpdate({ sessionUpdate: "plan", entries: planEntries(planSteps) });
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Adopt a freshly approved plan and show it to the client as a to-do list.
|
|
604
|
+
*
|
|
605
|
+
* Progress resets with the plan: `[DONE:n]` markers are relative to the plan
|
|
606
|
+
* that was approved, so carrying completions across a new one would mark
|
|
607
|
+
* steps of the new plan done that nobody has started.
|
|
608
|
+
*/
|
|
609
|
+
function adoptPlan(approvedPath) {
|
|
610
|
+
planPath = approvedPath;
|
|
611
|
+
planSteps = extractPlanSteps(readPlanFile(approvedPath));
|
|
612
|
+
completedSteps.clear();
|
|
613
|
+
notifyPlan();
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Advance the plan from `[DONE:n]` markers in the agent's own text.
|
|
617
|
+
*
|
|
618
|
+
* The plan is re-read rather than trusted from approval time because the
|
|
619
|
+
* agent is allowed to rewrite it while implementing (a 2-step plan becoming
|
|
620
|
+
* 12 is normal), and a frozen snapshot would report the wrong total and drop
|
|
621
|
+
* markers for steps it has never heard of.
|
|
622
|
+
*/
|
|
623
|
+
function refreshPlanProgress() {
|
|
624
|
+
if (planSteps.length === 0)
|
|
625
|
+
return;
|
|
626
|
+
let advanced = false;
|
|
627
|
+
for (const step of findCompletedMarkers(turnText)) {
|
|
628
|
+
if (completedSteps.has(step))
|
|
629
|
+
continue;
|
|
630
|
+
completedSteps.add(step);
|
|
631
|
+
advanced = true;
|
|
632
|
+
}
|
|
633
|
+
if (!advanced)
|
|
634
|
+
return;
|
|
635
|
+
const fresh = planPath ? extractPlanSteps(readPlanFile(planPath)) : [];
|
|
636
|
+
planSteps = markStepsCompleted(rebasePlanSteps(planSteps, fresh), completedSteps);
|
|
637
|
+
notifyPlan();
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Give the session a human-readable title, once.
|
|
641
|
+
*
|
|
642
|
+
* ACP expects this "after the first meaningful exchange", and GG already
|
|
643
|
+
* derives the same first-prompt title for its own session list — reusing it
|
|
644
|
+
* means a session is named identically on a phone, in the picker, and on
|
|
645
|
+
* disk instead of three near-misses.
|
|
646
|
+
*/
|
|
647
|
+
function notifySessionInfo(target) {
|
|
648
|
+
if (titleAnnounced || target !== session || !sessionId)
|
|
649
|
+
return;
|
|
650
|
+
const prompt = findUserSessionPrompt(target.getMessages()).replace(/\s+/g, " ").trim();
|
|
651
|
+
if (!prompt)
|
|
652
|
+
return;
|
|
653
|
+
titleAnnounced = true;
|
|
654
|
+
notifyUpdate({
|
|
655
|
+
sessionUpdate: "session_info_update",
|
|
656
|
+
title: prompt.length > 80 ? `${prompt.slice(0, 79)}…` : prompt,
|
|
657
|
+
updatedAt: new Date().toISOString(),
|
|
658
|
+
});
|
|
659
|
+
}
|
|
439
660
|
/**
|
|
440
661
|
* Tell the client the session mode changed outside a request it made — the
|
|
441
662
|
* model itself can enter/exit plan mode mid-run via the enter_plan/exit_plan
|
|
@@ -480,6 +701,39 @@ export async function runAcpMode(options) {
|
|
|
480
701
|
.catch(() => { });
|
|
481
702
|
}, 0);
|
|
482
703
|
}
|
|
704
|
+
/**
|
|
705
|
+
* The finished tool call's result as an ACP file diff, or undefined when we
|
|
706
|
+
* cannot honestly produce one.
|
|
707
|
+
*
|
|
708
|
+
* A real diff is what lets a client render a reviewable side-by-side edit
|
|
709
|
+
* instead of a wall of text. It REPLACES the tool's text result rather than
|
|
710
|
+
* accompanying it: `edit` already returns a unified diff as prose, and
|
|
711
|
+
* showing both means the same change twice in two formats.
|
|
712
|
+
*
|
|
713
|
+
* A failed call is left as text on purpose — the error message is the useful
|
|
714
|
+
* output, and the file on disk did not change.
|
|
715
|
+
*/
|
|
716
|
+
function diffContent(toolCallId, isError) {
|
|
717
|
+
const snapshot = diffSnapshots.get(toolCallId);
|
|
718
|
+
if (!snapshot)
|
|
719
|
+
return undefined;
|
|
720
|
+
diffSnapshots.delete(toolCallId);
|
|
721
|
+
if (isError || !snapshot.before)
|
|
722
|
+
return undefined;
|
|
723
|
+
const after = snapshotForDiff(snapshot.path);
|
|
724
|
+
// `newText` is required by the schema, so a file that vanished or grew past
|
|
725
|
+
// the diff budget mid-call falls back to the tool's own text output.
|
|
726
|
+
if (!after || after.text === null)
|
|
727
|
+
return undefined;
|
|
728
|
+
return [
|
|
729
|
+
{
|
|
730
|
+
type: "diff",
|
|
731
|
+
path: snapshot.path,
|
|
732
|
+
oldText: snapshot.before.text,
|
|
733
|
+
newText: after.text,
|
|
734
|
+
},
|
|
735
|
+
];
|
|
736
|
+
}
|
|
483
737
|
/**
|
|
484
738
|
* Bridge ggcoder's event bus onto `session/update` notifications.
|
|
485
739
|
*
|
|
@@ -493,8 +747,16 @@ export async function runAcpMode(options) {
|
|
|
493
747
|
bus.on("text_delta", ({ text }) => {
|
|
494
748
|
notifyUpdate({
|
|
495
749
|
sessionUpdate: "agent_message_chunk",
|
|
750
|
+
messageId: messageId(),
|
|
496
751
|
content: { type: "text", text },
|
|
497
752
|
});
|
|
753
|
+
// Plan markers arrive inside this text and can straddle two deltas, so
|
|
754
|
+
// the scan runs over the turn's accumulated text rather than the chunk.
|
|
755
|
+
// Only a delta that closes a bracket can complete a marker, which keeps
|
|
756
|
+
// this from re-scanning the whole turn on every token.
|
|
757
|
+
turnText += text;
|
|
758
|
+
if (text.includes("]"))
|
|
759
|
+
refreshPlanProgress();
|
|
498
760
|
}),
|
|
499
761
|
bus.on("thinking_delta", ({ text }) => {
|
|
500
762
|
notifyUpdate({
|
|
@@ -503,6 +765,19 @@ export async function runAcpMode(options) {
|
|
|
503
765
|
});
|
|
504
766
|
}),
|
|
505
767
|
bus.on("tool_call_start", ({ toolCallId, name, args }) => {
|
|
768
|
+
// A tool call ends the message it interrupted; whatever the agent says
|
|
769
|
+
// afterwards is a new one.
|
|
770
|
+
endMessage();
|
|
771
|
+
const locations = toolLocations(args, options.cwd);
|
|
772
|
+
// Snapshot BEFORE the tool runs. This handler is synchronous and the
|
|
773
|
+
// tool starts writing immediately after it, which is the only window
|
|
774
|
+
// where the file still holds its pre-edit contents.
|
|
775
|
+
if (DIFF_TOOLS.has(name) && locations[0]) {
|
|
776
|
+
diffSnapshots.set(toolCallId, {
|
|
777
|
+
path: locations[0].path,
|
|
778
|
+
before: snapshotForDiff(locations[0].path),
|
|
779
|
+
});
|
|
780
|
+
}
|
|
506
781
|
notifyUpdate({
|
|
507
782
|
sessionUpdate: "tool_call",
|
|
508
783
|
toolCallId,
|
|
@@ -511,6 +786,7 @@ export async function runAcpMode(options) {
|
|
|
511
786
|
kind: toolKind(name),
|
|
512
787
|
status: "in_progress",
|
|
513
788
|
rawInput: args,
|
|
789
|
+
...(locations.length > 0 ? { locations } : {}),
|
|
514
790
|
});
|
|
515
791
|
}),
|
|
516
792
|
// Mid-flight tool progress. The payload is tool-defined, so it rides in
|
|
@@ -529,7 +805,9 @@ export async function runAcpMode(options) {
|
|
|
529
805
|
sessionUpdate: "tool_call_update",
|
|
530
806
|
toolCallId,
|
|
531
807
|
status: isError ? "failed" : "completed",
|
|
532
|
-
content:
|
|
808
|
+
content: diffContent(toolCallId, isError) ?? [
|
|
809
|
+
{ type: "content", content: { type: "text", text: result } },
|
|
810
|
+
],
|
|
533
811
|
});
|
|
534
812
|
}),
|
|
535
813
|
// Turn-level outcomes are remembered rather than sent: ACP reports them
|
|
@@ -546,6 +824,11 @@ export async function runAcpMode(options) {
|
|
|
546
824
|
// compaction count — the drop the client watches for.
|
|
547
825
|
bus.on("turn_end", () => {
|
|
548
826
|
notifyUsage(target);
|
|
827
|
+
notifySessionInfo(target);
|
|
828
|
+
// The turn is over: the next chunk starts a new message, and the next
|
|
829
|
+
// turn's markers are scanned against its own text.
|
|
830
|
+
endMessage();
|
|
831
|
+
turnText = "";
|
|
549
832
|
}),
|
|
550
833
|
bus.on("compaction_end", () => {
|
|
551
834
|
notifyUsage(target);
|
|
@@ -557,54 +840,77 @@ export async function runAcpMode(options) {
|
|
|
557
840
|
off();
|
|
558
841
|
unwire = [];
|
|
559
842
|
}
|
|
843
|
+
/**
|
|
844
|
+
* Drop everything scoped to one session's lifetime. A new session inherits
|
|
845
|
+
* none of it: another session's plan progress, half-finished diffs or message
|
|
846
|
+
* numbering would all be reported as if they were its own.
|
|
847
|
+
*/
|
|
848
|
+
function resetSessionState() {
|
|
849
|
+
diffSnapshots.clear();
|
|
850
|
+
planPath = undefined;
|
|
851
|
+
planSteps = [];
|
|
852
|
+
completedSteps.clear();
|
|
853
|
+
turnText = "";
|
|
854
|
+
titleAnnounced = false;
|
|
855
|
+
currentMessageId = null;
|
|
856
|
+
messageSeq = 0;
|
|
857
|
+
}
|
|
560
858
|
async function disposeSession() {
|
|
561
859
|
if (!session)
|
|
562
860
|
return;
|
|
563
861
|
unwireAll();
|
|
862
|
+
resetSessionState();
|
|
564
863
|
const previous = session;
|
|
565
864
|
session = null;
|
|
566
865
|
sessionId = "";
|
|
567
866
|
await previous.dispose();
|
|
568
867
|
}
|
|
868
|
+
/**
|
|
869
|
+
* Plan mode. Supplying these callbacks is what registers the
|
|
870
|
+
* enter_plan/exit_plan tools at all — without them the mode exists but the
|
|
871
|
+
* model cannot move between states. GG Coder runs without approvals, so a
|
|
872
|
+
* submitted plan is auto-approved, the [DONE:n] contract is baked in so
|
|
873
|
+
* progress markers work as on the desktop, and the client is told about every
|
|
874
|
+
* mode change.
|
|
875
|
+
*
|
|
876
|
+
* They act on the CURRENT session rather than closing over one: a tool can
|
|
877
|
+
* only run inside a prompt, which is long after `startSession` published it.
|
|
878
|
+
*/
|
|
879
|
+
const planHooks = {
|
|
880
|
+
onEnterPlan: async () => {
|
|
881
|
+
await session?.setPlanMode(true);
|
|
882
|
+
notifyModeChange(MODE_PLAN);
|
|
883
|
+
},
|
|
884
|
+
onExitPlan: async (approvedPath) => {
|
|
885
|
+
await session?.setPlanMode(false);
|
|
886
|
+
await session?.setApprovedPlan(approvedPath);
|
|
887
|
+
notifyModeChange(MODE_DEFAULT);
|
|
888
|
+
// The approved plan becomes the client's to-do list, which then advances
|
|
889
|
+
// from the [DONE:n] markers the returned instruction asks for.
|
|
890
|
+
adoptPlan(approvedPath);
|
|
891
|
+
return "Plan approved. Proceed with implementation, marking each completed step with [DONE:n].";
|
|
892
|
+
},
|
|
893
|
+
};
|
|
569
894
|
const createSession = options.createSession ??
|
|
570
|
-
((signal) => {
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
// enter_plan/exit_plan tools at all — without them the mode exists but
|
|
590
|
-
// the model cannot move between states. GG Coder runs without
|
|
591
|
-
// approvals, so a submitted plan is auto-approved, the [DONE:n]
|
|
592
|
-
// contract is baked in so progress markers work as on the desktop, and
|
|
593
|
-
// the client is told about every mode change.
|
|
594
|
-
onEnterPlan: async () => {
|
|
595
|
-
await created.setPlanMode(true);
|
|
596
|
-
notifyModeChange(MODE_PLAN);
|
|
597
|
-
},
|
|
598
|
-
onExitPlan: async (planPath) => {
|
|
599
|
-
await created.setPlanMode(false);
|
|
600
|
-
await created.setApprovedPlan(planPath);
|
|
601
|
-
notifyModeChange(MODE_DEFAULT);
|
|
602
|
-
return "Plan approved. Proceed with implementation, marking each completed step with [DONE:n].";
|
|
603
|
-
},
|
|
604
|
-
signal,
|
|
605
|
-
});
|
|
606
|
-
return created;
|
|
607
|
-
});
|
|
895
|
+
((signal, hooks) => new AgentSession({
|
|
896
|
+
provider: options.provider,
|
|
897
|
+
model: options.model,
|
|
898
|
+
cwd: options.cwd,
|
|
899
|
+
baseUrl: options.baseUrl,
|
|
900
|
+
systemPrompt: options.systemPrompt,
|
|
901
|
+
thinkingLevel: options.thinkingLevel,
|
|
902
|
+
// MCP connect (spawning stdio servers, HTTP handshakes) takes seconds
|
|
903
|
+
// and would otherwise sit on the critical path of session/new and
|
|
904
|
+
// session/load. The desktop sidecar already ships this path: the tool
|
|
905
|
+
// catalog is seeded from the disk cache so tools are visible
|
|
906
|
+
// immediately, and live connections promote in the background. A phone
|
|
907
|
+
// client gets its session in milliseconds and the same tools a moment
|
|
908
|
+
// later.
|
|
909
|
+
backgroundMcpConnect: true,
|
|
910
|
+
onEnterPlan: hooks.onEnterPlan,
|
|
911
|
+
onExitPlan: hooks.onExitPlan,
|
|
912
|
+
signal,
|
|
913
|
+
}));
|
|
608
914
|
// ── Method handlers ──────────────────────────────────────
|
|
609
915
|
function handleInitialize() {
|
|
610
916
|
return {
|
|
@@ -615,7 +921,7 @@ export async function runAcpMode(options) {
|
|
|
615
921
|
mcpCapabilities: { http: false, sse: false, acp: false },
|
|
616
922
|
// `{}` is how ACP says "supported" for a capability with no options of
|
|
617
923
|
// its own. Omitting the key means unsupported, so this is not cosmetic.
|
|
618
|
-
sessionCapabilities: { list: {}, resume: {} },
|
|
924
|
+
sessionCapabilities: { list: {}, resume: {}, close: {}, delete: {} },
|
|
619
925
|
},
|
|
620
926
|
authMethods: [],
|
|
621
927
|
agentInfo: { name: "ggcoder", title: "GG Coder", version: options.version },
|
|
@@ -632,7 +938,7 @@ export async function runAcpMode(options) {
|
|
|
632
938
|
// on it, so the old one is stopped first, deliberately and visibly.
|
|
633
939
|
await disposeSession();
|
|
634
940
|
abort = new AbortController();
|
|
635
|
-
const created = createSession(abort.signal);
|
|
941
|
+
const created = createSession(abort.signal, planHooks);
|
|
636
942
|
await created.initialize();
|
|
637
943
|
if (restorePath)
|
|
638
944
|
await created.loadSession(restorePath);
|
|
@@ -644,7 +950,7 @@ export async function runAcpMode(options) {
|
|
|
644
950
|
async function handleNewSession() {
|
|
645
951
|
const created = await startSession();
|
|
646
952
|
notifyAvailableCommands(created);
|
|
647
|
-
|
|
953
|
+
announceSessionSoon(created);
|
|
648
954
|
return { sessionId, configOptions: configOptionsFor(created), modes: sessionModes(created) };
|
|
649
955
|
}
|
|
650
956
|
/** The directory a request is about, defaulting to the one we were started in. */
|
|
@@ -714,9 +1020,91 @@ export async function runAcpMode(options) {
|
|
|
714
1020
|
for (const update of historyUpdates(displayMessages))
|
|
715
1021
|
notifyUpdate(update);
|
|
716
1022
|
notifyAvailableCommands(restored);
|
|
717
|
-
|
|
1023
|
+
announceSessionSoon(restored);
|
|
718
1024
|
return { configOptions: configOptionsFor(restored), modes: sessionModes(restored) };
|
|
719
1025
|
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Abort the running turn and WAIT for it to unwind.
|
|
1028
|
+
*
|
|
1029
|
+
* `handleCancel` only signals: `session.prompt()` keeps unwinding after it
|
|
1030
|
+
* returns, and its last act is persisting the turn. Disposing before that
|
|
1031
|
+
* finishes clears the session path out from under the write, so the final
|
|
1032
|
+
* exchange is silently dropped and the session is missing its tail when the
|
|
1033
|
+
* user comes back to it. The read loop's own teardown already waits like
|
|
1034
|
+
* this; a lifecycle request that tears a session down mid-turn must too.
|
|
1035
|
+
*/
|
|
1036
|
+
async function cancelAndSettle() {
|
|
1037
|
+
handleCancel();
|
|
1038
|
+
await Promise.allSettled([...inFlight]);
|
|
1039
|
+
}
|
|
1040
|
+
/** The sessionId a lifecycle request names, validated. */
|
|
1041
|
+
function requestedSessionId(params, method) {
|
|
1042
|
+
const requested = params?.sessionId;
|
|
1043
|
+
if (typeof requested !== "string" || !requested) {
|
|
1044
|
+
throw new InvalidParams(`${method} requires a sessionId.`);
|
|
1045
|
+
}
|
|
1046
|
+
return requested;
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* Reconnect to a stored session WITHOUT replaying it.
|
|
1050
|
+
*
|
|
1051
|
+
* The difference from `session/load` is the whole point: a client that still
|
|
1052
|
+
* holds the transcript (it was showing this session a moment ago) wants the
|
|
1053
|
+
* agent-side context back, not a second copy of every message pushed at it.
|
|
1054
|
+
*/
|
|
1055
|
+
async function handleResumeSession(params) {
|
|
1056
|
+
const requested = requestedSessionId(params, "session/resume");
|
|
1057
|
+
const sessionPath = await findSessionById(requested, requestCwd(params));
|
|
1058
|
+
if (!sessionPath)
|
|
1059
|
+
throw new InvalidParams(`Unknown session '${requested}'.`);
|
|
1060
|
+
const restored = await startSession(sessionPath);
|
|
1061
|
+
// As in session/load: the client keeps addressing the id it asked for.
|
|
1062
|
+
sessionId = requested;
|
|
1063
|
+
notifyAvailableCommands(restored);
|
|
1064
|
+
announceSessionSoon(restored);
|
|
1065
|
+
return { configOptions: configOptionsFor(restored), modes: sessionModes(restored) };
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Close the active session, cancelling whatever it is doing.
|
|
1069
|
+
*
|
|
1070
|
+
* The spec requires the in-flight turn to be cancelled exactly as
|
|
1071
|
+
* `session/cancel` would, so this reuses that path rather than tearing the
|
|
1072
|
+
* session down underneath a running agent loop.
|
|
1073
|
+
*/
|
|
1074
|
+
async function handleCloseSession(params) {
|
|
1075
|
+
const requested = requestedSessionId(params, "session/close");
|
|
1076
|
+
if (!session || requested !== sessionId) {
|
|
1077
|
+
throw new InvalidParams(`Session '${requested}' is not active.`);
|
|
1078
|
+
}
|
|
1079
|
+
await cancelAndSettle();
|
|
1080
|
+
await disposeSession();
|
|
1081
|
+
return {};
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Delete a stored session from disk.
|
|
1085
|
+
*
|
|
1086
|
+
* Hard delete, including the archive and asset siblings, because a session
|
|
1087
|
+
* left half-present would come back as a broken row in the next
|
|
1088
|
+
* `session/list`. Deleting something that is not there succeeds silently:
|
|
1089
|
+
* the spec asks for idempotence, and the user's intent is already satisfied.
|
|
1090
|
+
*/
|
|
1091
|
+
async function handleDeleteSession(params) {
|
|
1092
|
+
const requested = requestedSessionId(params, "session/delete");
|
|
1093
|
+
const sessionPath = await findSessionById(requested, requestCwd(params));
|
|
1094
|
+
if (!sessionPath)
|
|
1095
|
+
return {};
|
|
1096
|
+
// Deleting the session we are serving would leave a live AgentSession
|
|
1097
|
+
// appending to a file that no longer exists, quietly recreating it.
|
|
1098
|
+
if (session && requested === sessionId) {
|
|
1099
|
+
await cancelAndSettle();
|
|
1100
|
+
await disposeSession();
|
|
1101
|
+
}
|
|
1102
|
+
const group = sessionGroupPaths(sessionPath);
|
|
1103
|
+
for (const target of [group.plainPath, group.archivePath, group.assetsPath]) {
|
|
1104
|
+
await rm(target, { recursive: true, force: true });
|
|
1105
|
+
}
|
|
1106
|
+
return {};
|
|
1107
|
+
}
|
|
720
1108
|
/**
|
|
721
1109
|
* Switch session mode (ACP `session/set_mode`; Zed's mode picker uses this,
|
|
722
1110
|
* pew2 routes it through session/set_config_option with configId "mode").
|
|
@@ -828,6 +1216,13 @@ export async function runAcpMode(options) {
|
|
|
828
1216
|
}
|
|
829
1217
|
finally {
|
|
830
1218
|
running = false;
|
|
1219
|
+
// Drop any before-snapshot whose tool never reported an end. A cancelled
|
|
1220
|
+
// turn stops emitting tool events, so `diffContent` — the only other
|
|
1221
|
+
// place these are removed — never runs for the call that was in flight,
|
|
1222
|
+
// and its file contents would stay pinned for the rest of the session.
|
|
1223
|
+
// Cleared here rather than on `turn_end`, which fires before that turn's
|
|
1224
|
+
// tools execute and would discard snapshots still in use.
|
|
1225
|
+
diffSnapshots.clear();
|
|
831
1226
|
}
|
|
832
1227
|
return { stopReason: cancelled ? "cancelled" : stopReasonFor(truncation, hitMaxTurns) };
|
|
833
1228
|
}
|
|
@@ -854,6 +1249,12 @@ export async function runAcpMode(options) {
|
|
|
854
1249
|
return handleListSessions(params);
|
|
855
1250
|
case "session/load":
|
|
856
1251
|
return handleLoadSession(params);
|
|
1252
|
+
case "session/resume":
|
|
1253
|
+
return handleResumeSession(params);
|
|
1254
|
+
case "session/close":
|
|
1255
|
+
return handleCloseSession(params);
|
|
1256
|
+
case "session/delete":
|
|
1257
|
+
return handleDeleteSession(params);
|
|
857
1258
|
case "session/set_config_option":
|
|
858
1259
|
return handleSetConfigOption(params);
|
|
859
1260
|
case "session/set_mode":
|