@kenkaiiii/ggcoder 5.32.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.
@@ -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` 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.
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",
@@ -393,6 +534,129 @@ export async function runAcpMode(options) {
393
534
  params: { sessionId, update },
394
535
  });
395
536
  }
537
+ /**
538
+ * Report context-window usage to the client.
539
+ *
540
+ * Sent whenever token accounting moves — after each model response and after
541
+ * a compaction — because a client's context meter is otherwise frozen at
542
+ * whatever it last inferred. Compaction is only visible to a client as a drop
543
+ * in `used` at unchanged `size`, so the post-compaction emit is what makes
544
+ * that detectable at all.
545
+ *
546
+ * `used`/`size` are required by the schema; a session that cannot count
547
+ * tokens sends nothing rather than a zero, which would render as an empty
548
+ * context the user does not have.
549
+ */
550
+ function notifyUsage(target = session) {
551
+ if (!target || target !== session || !sessionId)
552
+ return;
553
+ const usage = target.getContextUsage?.();
554
+ if (!usage || !Number.isFinite(usage.used) || !Number.isFinite(usage.size))
555
+ return;
556
+ notifyUpdate({
557
+ sessionUpdate: "usage_update",
558
+ used: usage.used,
559
+ size: usage.size,
560
+ ...(usage.costUsd === undefined ? {} : { cost: { amount: usage.costUsd, currency: "USD" } }),
561
+ });
562
+ }
563
+ /**
564
+ * State for a session that was just created or restored, sent after the
565
+ * response that told the client the session exists.
566
+ *
567
+ * Same deferral (and same staleness guard) as {@link notifyAvailableCommands}:
568
+ * a notification addressed to a sessionId the client has not seen yet has
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.
571
+ */
572
+ function announceSessionSoon(target) {
573
+ const forSession = sessionId;
574
+ setTimeout(() => {
575
+ if (session !== target || sessionId !== forSession)
576
+ return;
577
+ notifyUsage(target);
578
+ notifySessionInfo(target);
579
+ }, 0);
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
+ }
396
660
  /**
397
661
  * Tell the client the session mode changed outside a request it made — the
398
662
  * model itself can enter/exit plan mode mid-run via the enter_plan/exit_plan
@@ -437,6 +701,39 @@ export async function runAcpMode(options) {
437
701
  .catch(() => { });
438
702
  }, 0);
439
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
+ }
440
737
  /**
441
738
  * Bridge ggcoder's event bus onto `session/update` notifications.
442
739
  *
@@ -450,8 +747,16 @@ export async function runAcpMode(options) {
450
747
  bus.on("text_delta", ({ text }) => {
451
748
  notifyUpdate({
452
749
  sessionUpdate: "agent_message_chunk",
750
+ messageId: messageId(),
453
751
  content: { type: "text", text },
454
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();
455
760
  }),
456
761
  bus.on("thinking_delta", ({ text }) => {
457
762
  notifyUpdate({
@@ -460,6 +765,19 @@ export async function runAcpMode(options) {
460
765
  });
461
766
  }),
462
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
+ }
463
781
  notifyUpdate({
464
782
  sessionUpdate: "tool_call",
465
783
  toolCallId,
@@ -468,6 +786,7 @@ export async function runAcpMode(options) {
468
786
  kind: toolKind(name),
469
787
  status: "in_progress",
470
788
  rawInput: args,
789
+ ...(locations.length > 0 ? { locations } : {}),
471
790
  });
472
791
  }),
473
792
  // Mid-flight tool progress. The payload is tool-defined, so it rides in
@@ -486,7 +805,9 @@ export async function runAcpMode(options) {
486
805
  sessionUpdate: "tool_call_update",
487
806
  toolCallId,
488
807
  status: isError ? "failed" : "completed",
489
- content: [{ type: "content", content: { type: "text", text: result } }],
808
+ content: diffContent(toolCallId, isError) ?? [
809
+ { type: "content", content: { type: "text", text: result } },
810
+ ],
490
811
  });
491
812
  }),
492
813
  // Turn-level outcomes are remembered rather than sent: ACP reports them
@@ -497,6 +818,21 @@ export async function runAcpMode(options) {
497
818
  bus.on("max_turns", () => {
498
819
  hitMaxTurns = true;
499
820
  }),
821
+ // Token accounting changes: after every model response, and after a
822
+ // compaction rebuilds the context. `compaction_end` fires once the
823
+ // compacted messages are installed, so the emit carries the POST-
824
+ // compaction count — the drop the client watches for.
825
+ bus.on("turn_end", () => {
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 = "";
832
+ }),
833
+ bus.on("compaction_end", () => {
834
+ notifyUsage(target);
835
+ }),
500
836
  ];
501
837
  }
502
838
  function unwireAll() {
@@ -504,54 +840,77 @@ export async function runAcpMode(options) {
504
840
  off();
505
841
  unwire = [];
506
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
+ }
507
858
  async function disposeSession() {
508
859
  if (!session)
509
860
  return;
510
861
  unwireAll();
862
+ resetSessionState();
511
863
  const previous = session;
512
864
  session = null;
513
865
  sessionId = "";
514
866
  await previous.dispose();
515
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
+ };
516
894
  const createSession = options.createSession ??
517
- ((signal) => {
518
- // Self-reference is safe: the callbacks only run once the agent loop is
519
- // executing tools, long after the constructor returns.
520
- const created = new AgentSession({
521
- provider: options.provider,
522
- model: options.model,
523
- cwd: options.cwd,
524
- baseUrl: options.baseUrl,
525
- systemPrompt: options.systemPrompt,
526
- thinkingLevel: options.thinkingLevel,
527
- // MCP connect (spawning stdio servers, HTTP handshakes) takes seconds
528
- // and would otherwise sit on the critical path of session/new and
529
- // session/load. The desktop sidecar already ships this path: the tool
530
- // catalog is seeded from the disk cache so tools are visible
531
- // immediately, and live connections promote in the background. A phone
532
- // client gets its session in milliseconds and the same tools a moment
533
- // later.
534
- backgroundMcpConnect: true,
535
- // Plan mode. Supplying these callbacks is what registers the
536
- // enter_plan/exit_plan tools at all — without them the mode exists but
537
- // the model cannot move between states. GG Coder runs without
538
- // approvals, so a submitted plan is auto-approved, the [DONE:n]
539
- // contract is baked in so progress markers work as on the desktop, and
540
- // the client is told about every mode change.
541
- onEnterPlan: async () => {
542
- await created.setPlanMode(true);
543
- notifyModeChange(MODE_PLAN);
544
- },
545
- onExitPlan: async (planPath) => {
546
- await created.setPlanMode(false);
547
- await created.setApprovedPlan(planPath);
548
- notifyModeChange(MODE_DEFAULT);
549
- return "Plan approved. Proceed with implementation, marking each completed step with [DONE:n].";
550
- },
551
- signal,
552
- });
553
- return created;
554
- });
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
+ }));
555
914
  // ── Method handlers ──────────────────────────────────────
556
915
  function handleInitialize() {
557
916
  return {
@@ -562,7 +921,7 @@ export async function runAcpMode(options) {
562
921
  mcpCapabilities: { http: false, sse: false, acp: false },
563
922
  // `{}` is how ACP says "supported" for a capability with no options of
564
923
  // its own. Omitting the key means unsupported, so this is not cosmetic.
565
- sessionCapabilities: { list: {}, resume: {} },
924
+ sessionCapabilities: { list: {}, resume: {}, close: {}, delete: {} },
566
925
  },
567
926
  authMethods: [],
568
927
  agentInfo: { name: "ggcoder", title: "GG Coder", version: options.version },
@@ -579,7 +938,7 @@ export async function runAcpMode(options) {
579
938
  // on it, so the old one is stopped first, deliberately and visibly.
580
939
  await disposeSession();
581
940
  abort = new AbortController();
582
- const created = createSession(abort.signal);
941
+ const created = createSession(abort.signal, planHooks);
583
942
  await created.initialize();
584
943
  if (restorePath)
585
944
  await created.loadSession(restorePath);
@@ -591,6 +950,7 @@ export async function runAcpMode(options) {
591
950
  async function handleNewSession() {
592
951
  const created = await startSession();
593
952
  notifyAvailableCommands(created);
953
+ announceSessionSoon(created);
594
954
  return { sessionId, configOptions: configOptionsFor(created), modes: sessionModes(created) };
595
955
  }
596
956
  /** The directory a request is about, defaulting to the one we were started in. */
@@ -660,8 +1020,91 @@ export async function runAcpMode(options) {
660
1020
  for (const update of historyUpdates(displayMessages))
661
1021
  notifyUpdate(update);
662
1022
  notifyAvailableCommands(restored);
1023
+ announceSessionSoon(restored);
663
1024
  return { configOptions: configOptionsFor(restored), modes: sessionModes(restored) };
664
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
+ }
665
1108
  /**
666
1109
  * Switch session mode (ACP `session/set_mode`; Zed's mode picker uses this,
667
1110
  * pew2 routes it through session/set_config_option with configId "mode").
@@ -773,6 +1216,13 @@ export async function runAcpMode(options) {
773
1216
  }
774
1217
  finally {
775
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();
776
1226
  }
777
1227
  return { stopReason: cancelled ? "cancelled" : stopReasonFor(truncation, hitMaxTurns) };
778
1228
  }
@@ -799,6 +1249,12 @@ export async function runAcpMode(options) {
799
1249
  return handleListSessions(params);
800
1250
  case "session/load":
801
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);
802
1258
  case "session/set_config_option":
803
1259
  return handleSetConfigOption(params);
804
1260
  case "session/set_mode":