@kenkaiiii/ggcoder 5.79.3 → 5.80.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.
@@ -25,18 +25,35 @@ import path from "node:path";
25
25
  import { readFileSync, statSync } from "node:fs";
26
26
  import { rm } from "node:fs/promises";
27
27
  import { isAbortError } from "@kenkaiiii/gg-agent";
28
- import { getAllModels, getMaxThinkingLevel, getModel } from "@kenkaiiii/gg-core";
28
+ import { getAllModels, getAppPaths, getMaxThinkingLevel, getModel } from "@kenkaiiii/gg-core";
29
29
  import { AgentSession } from "../core/agent-session.js";
30
+ import { createAskUserBridge, formatLateAnswer, } from "../core/ask-user.js";
31
+ import { createAskUserTool } from "../tools/ask-user.js";
32
+ import { deleteGoal, goalKickoffPrompt, goalNeedsKickoff, goalPhase, goalResumePrompt, goalSettingsNote, goalWasInterrupted, goalsRoot, isGoalDir, markGoalKickoff, readGoal, setGoalCritterModel, setGoalPaused, startGoalOnAnswer, updateGoal, } from "../core/goals-store.js";
33
+ import { GOAL_RUNTIME_PROVENANCE } from "../core/goal-summary.js";
34
+ import { createGoalsAgentSession, goalCritterPick, loadGoalBriefExtras, } from "../goals-agent/goals-agent.js";
35
+ import { prepareAttachments } from "../app-sidecar/attachments.js";
36
+ import { acpImageAttachments, createAcpGoal, goalWorkers, listAcpGoals } from "./acp-goals.js";
30
37
  import { PROMPT_COMMANDS } from "../core/prompt-commands.js";
31
38
  import { loadCustomCommands } from "../core/custom-commands.js";
32
39
  import { findSessionById, listAllSessions, listSessionSummaries, loadSessionCheckpointChain, } from "../session.js";
33
- import { getHistoryMessageVisibility, reconstructCheckpointHistory, restoreUserRow, } from "../core/session-history.js";
40
+ import { getHistoryMessageVisibility, normalizeAutopilotMarkersForHistory, reconstructCheckpointHistory, restoreUserRow, } from "../core/session-history.js";
34
41
  import { findUserSessionPrompt } from "../core/session-preview.js";
35
42
  import { sessionGroupPaths } from "../core/session-storage.js";
36
43
  import { extractPlanSteps, findCompletedMarkers, markStepsCompleted, rebasePlanSteps, } from "../utils/plan-steps.js";
37
44
  import { formatUserError } from "../utils/error-handler.js";
38
45
  import { closeLogger } from "../core/logger.js";
39
46
  import { acpToolImages } from "./acp-images.js";
47
+ import { buildUserPlanFeedbackPrompt, IMPLEMENT_PLAN_PROMPT, PLAN_SUBMITTED_FOR_REVIEW, shouldOfferPendingPlan, } from "../core/plan-handoff.js";
48
+ import { readChecklistSnapshot } from "../core/checklist-snapshot.js";
49
+ import { CHECKLIST_FILE } from "../core/checklist-items.js";
50
+ import { loadAutopilot, saveAutopilot } from "../app-sidecar/app-settings.js";
51
+ import { formatErrorForDisplay } from "@kenkaiiii/gg-ai";
52
+ import { buildKenAutopilotContext, buildKenAutopilotPlanContext } from "../core/ken-context.js";
53
+ import { parseAutopilotVerdict } from "../core/autopilot-verdict.js";
54
+ import { countAssistantMessages, extractTurnToolCalls, isMechanicalOnlyTurn, isWorkflowCommandText, shouldStartAutopilotCycle, } from "../core/autopilot-gate.js";
55
+ import { driveAutopilotCycle, frameAutopilotInjection, } from "../core/autopilot-cycle.js";
56
+ import { createAcpKenReviewer } from "./acp-autopilot.js";
40
57
  /** The ACP major version this mode implements. Bumped only for breaking changes. */
41
58
  export const ACP_PROTOCOL_VERSION = 1;
42
59
  /** JSON-RPC 2.0 reserved codes. ACP adds no codes of its own. */
@@ -54,6 +71,14 @@ const RPC_INTERNAL_ERROR = -32603;
54
71
  */
55
72
  class InvalidParams extends Error {
56
73
  }
74
+ /** Desktop MAX_AUTOPILOT_ROUNDS (app-sidecar.ts). */
75
+ const MAX_AUTOPILOT_ROUNDS = 3;
76
+ /** Autopilot-injected runs: hidden, as on the desktop (autopilot-runner.ts). */
77
+ const AUTOMATION_PROVENANCE = {
78
+ source: "runtime",
79
+ kind: "automation",
80
+ visibility: "hidden",
81
+ };
57
82
  // ── Tool mapping ───────────────────────────────────────────
58
83
  /**
59
84
  * ggcoder tool name → ACP `ToolKind`.
@@ -408,16 +433,57 @@ function messageText(content) {
408
433
  * the protocol needs of them.
409
434
  */
410
435
  export function historyUpdates(messages) {
411
- const updates = [];
436
+ return historyFrames(messages, []).flatMap((f) => ("update" in f ? [f.update] : []));
437
+ }
438
+ /**
439
+ * {@link historyUpdates} with Ken's verdict markers interleaved at their
440
+ * `afterMessageCount` (non-system messages, as the desktop history route
441
+ * counts them). Marker phase `plan_approved` replays as ACP `plan_accepted`.
442
+ */
443
+ export function historyFrames(messages, markers) {
444
+ const frames = [];
445
+ const updates = {
446
+ push: (update) => frames.push({ update }),
447
+ };
448
+ const byCount = new Map();
449
+ const nonSystem = messages.filter((m) => m.role !== "system").length;
450
+ for (const marker of normalizeAutopilotMarkersForHistory(markers, nonSystem)) {
451
+ const list = byCount.get(marker.afterMessageCount) ?? [];
452
+ list.push(marker);
453
+ byCount.set(marker.afterMessageCount, list);
454
+ }
455
+ const flush = (count) => {
456
+ for (const marker of byCount.get(count) ?? []) {
457
+ frames.push({
458
+ autopilot: {
459
+ phase: marker.phase === "plan_approved" ? "plan_accepted" : marker.phase,
460
+ ...(marker.reason !== undefined ? { reason: marker.reason } : {}),
461
+ ...(marker.body !== undefined ? { body: marker.body } : {}),
462
+ history: true,
463
+ },
464
+ });
465
+ }
466
+ byCount.delete(count);
467
+ };
412
468
  let replayed = 0;
469
+ let counted = 0;
413
470
  const nextMessageId = () => `hist-${++replayed}`;
471
+ flush(0);
414
472
  for (const message of messages) {
473
+ replayMessage(message);
474
+ if (message.role !== "system") {
475
+ counted += 1;
476
+ flush(counted);
477
+ }
478
+ }
479
+ return frames;
480
+ function replayMessage(message) {
415
481
  if (getHistoryMessageVisibility(message) === "hidden")
416
- continue;
482
+ return;
417
483
  if (message.role === "user") {
418
484
  const restored = restoreUserRow(message.content, message.provenance);
419
485
  if (restored.autopilotInjected || restored.notification)
420
- continue;
486
+ return;
421
487
  if (restored.text) {
422
488
  updates.push({
423
489
  sessionUpdate: "user_message_chunk",
@@ -425,7 +491,7 @@ export function historyUpdates(messages) {
425
491
  content: { type: "text", text: restored.text },
426
492
  });
427
493
  }
428
- continue;
494
+ return;
429
495
  }
430
496
  if (message.role === "assistant") {
431
497
  const text = messageText(message.content);
@@ -455,7 +521,7 @@ export function historyUpdates(messages) {
455
521
  });
456
522
  }
457
523
  }
458
- continue;
524
+ return;
459
525
  }
460
526
  for (const result of message.content) {
461
527
  const entry = result;
@@ -473,7 +539,6 @@ export function historyUpdates(messages) {
473
539
  });
474
540
  }
475
541
  }
476
- return updates;
477
542
  }
478
543
  // ── Mode ───────────────────────────────────────────────────
479
544
  /**
@@ -529,6 +594,217 @@ export async function runAcpMode(options) {
529
594
  let turnText = "";
530
595
  /** Whether this session has already announced a title to the client. */
531
596
  let titleAnnounced = false;
597
+ // ── GG extensions (namespace `gg`) ──
598
+ const agentDir = options.agentDir ?? getAppPaths().agentDir;
599
+ /** The client answers `_gg/ask_user` (clientCapabilities._meta.gg.askUser). */
600
+ let clientAsksUser = false;
601
+ /** The client reviews plans (`clientCapabilities._meta.gg.planReview`). */
602
+ let clientPlanReview = false;
603
+ /** The client sent any `clientCapabilities._meta.gg` (reads `_gg/*` notifications). */
604
+ let clientSpeaksGg = false;
605
+ /** Whether the CURRENT session was built with plan review on. */
606
+ let sessionPlanReview = false;
607
+ /** Pending plan, reachable by the autopilot cycle. */
608
+ const pendingPlan = {
609
+ planPath: null,
610
+ content: "",
611
+ generation: 0,
612
+ offeredGeneration: -1,
613
+ };
614
+ /** Autopilot (Ken) state for the session's project; `reviewing` is set by the cycle. */
615
+ const autopilot = { enabled: false, reviewing: false };
616
+ /**
617
+ * The cycle in flight inside a `session/prompt`. `stop` is set by
618
+ * session/cancel ("cancelled") or a queued message during review ("ignored").
619
+ */
620
+ const cycle = {
621
+ active: false,
622
+ stop: null,
623
+ };
624
+ /** Ken's reviewer for the current session, built on first review. */
625
+ let kenReviewer = null;
626
+ /** The text of the user turn the cycle reviews (pinned in Ken's digest). */
627
+ let originalRequest = "";
628
+ /** Bodies Ken injected this conversation, framed (digest labelling). */
629
+ let injectedPrompts = [];
630
+ /** The active session's working directory: the goal folder for a goal session. */
631
+ let sessionCwd = options.cwd;
632
+ /** The goal the active session coordinates, kept current as it changes. */
633
+ let goal = null;
634
+ /** The queue as last reported, to tell which entries a `queue_drained` took. */
635
+ let knownQueue = [];
636
+ /** Trailing throttle for worker-driven `_gg/goal_state` (≤ 4/s). */
637
+ const GOAL_STATE_INTERVAL_MS = 250;
638
+ let goalStateTimer = null;
639
+ let lastGoalStateAt = 0;
640
+ function queueSnapshot() {
641
+ return session?.listQueuedMessages?.() ?? [];
642
+ }
643
+ /** `_gg/queue_update`: the current queue, plus what was drained or returned. */
644
+ function notifyQueue(extra) {
645
+ const queued = queueSnapshot();
646
+ knownQueue = queued;
647
+ write({
648
+ jsonrpc: "2.0",
649
+ method: "_gg/queue_update",
650
+ params: { sessionId, queued, ...extra },
651
+ });
652
+ }
653
+ /**
654
+ * Hand whatever is still queued back to the client instead of running it:
655
+ * on `session/cancel`, and at a turn end that never reached a steering
656
+ * boundary (`turnEnded`, so the client sends it as the next prompt).
657
+ */
658
+ function returnQueue(turnEnded) {
659
+ const left = queueSnapshot();
660
+ if (left.length === 0 || !session?.drainQueue)
661
+ return;
662
+ session.drainQueue();
663
+ notifyQueue({ returned: left, ...(turnEnded ? { turnEnded: true } : {}) });
664
+ }
665
+ function goalStatePayload() {
666
+ if (!goal || !session)
667
+ return null;
668
+ const { record } = goal;
669
+ return {
670
+ sessionId,
671
+ goalId: record.id,
672
+ phase: goalPhase(record),
673
+ paused: Boolean(record.pausedAt),
674
+ critterModel: goalCritterPick(record)?.model ?? null,
675
+ workers: goalWorkers(session.listSubAgents?.() ?? []),
676
+ };
677
+ }
678
+ /** `_gg/goal_state`, now: full state each time. */
679
+ function notifyGoalState() {
680
+ if (goalStateTimer) {
681
+ clearTimeout(goalStateTimer);
682
+ goalStateTimer = null;
683
+ }
684
+ const params = goalStatePayload();
685
+ if (!params)
686
+ return;
687
+ lastGoalStateAt = Date.now();
688
+ write({ jsonrpc: "2.0", method: "_gg/goal_state", params });
689
+ }
690
+ /** `_gg/goal_state` for worker churn, at most every 250ms (trailing). */
691
+ function notifyGoalStateSoon() {
692
+ if (goalStateTimer || !goal)
693
+ return;
694
+ const wait = Math.max(0, GOAL_STATE_INTERVAL_MS - (Date.now() - lastGoalStateAt));
695
+ goalStateTimer = setTimeout(() => {
696
+ goalStateTimer = null;
697
+ notifyGoalState();
698
+ }, wait);
699
+ }
700
+ /** `_meta.gg.goal` for session/new|load|resume responses on a goal session. */
701
+ function goalMeta(target) {
702
+ if (!goal)
703
+ return {};
704
+ const { record } = goal;
705
+ return {
706
+ _meta: {
707
+ gg: {
708
+ goal: {
709
+ id: record.id,
710
+ phase: goalPhase(record),
711
+ paused: Boolean(record.pausedAt),
712
+ needsKickoff: goalNeedsKickoff(record, target.getMessages()),
713
+ },
714
+ },
715
+ },
716
+ };
717
+ }
718
+ /**
719
+ * `_gg/ask_user`, on the desktop's ask bridge: the JSON-RPC id is the
720
+ * bridge's question id. Past the soft deadline the model is told "no answer
721
+ * yet" and proceeds, while the client's request stays OPEN; a late answer
722
+ * goes to {@link deliverLateAnswer}. A JSON-RPC error or malformed reply
723
+ * counts as cancel; `session/cancel`, close and disconnect cancel everything
724
+ * (including deferred questions, whose later replies are then ignored).
725
+ */
726
+ const asks = createAskUserBridge({
727
+ broadcast: (prompt) => write({
728
+ jsonrpc: "2.0",
729
+ id: prompt.id,
730
+ method: "_gg/ask_user",
731
+ params: { sessionId, questions: prompt.questions },
732
+ }),
733
+ ...(options.askUserDeadlineMs !== undefined ? { timeoutMs: options.askUserDeadlineMs } : {}),
734
+ onLateAnswer: (late) => void deliverLateAnswer(late),
735
+ });
736
+ /**
737
+ * A late answer reaches the agent the way the desktop delivers it: a goal
738
+ * start is recorded at once, then the "Late answer to:" text joins the queue.
739
+ * Mid-run it is steering (reported like any queued message). Idle, ggcoder
740
+ * never starts a turn itself, so it is handed back as `returned` with
741
+ * `turnEnded` and the client sends it as the next prompt.
742
+ */
743
+ async function deliverLateAnswer(late) {
744
+ const target = session;
745
+ await startGoalFrom({ action: "answer", answers: late.answers }, late.prompt.questions);
746
+ if (!target || target !== session)
747
+ return;
748
+ const text = formatLateAnswer(late);
749
+ if (running && target.queueMessage) {
750
+ target.queueMessage(text);
751
+ notifyQueue({});
752
+ return;
753
+ }
754
+ write({
755
+ jsonrpc: "2.0",
756
+ method: "_gg/queue_update",
757
+ params: {
758
+ sessionId,
759
+ queued: queueSnapshot(),
760
+ returned: [{ id: `late-${late.prompt.id}`, text }],
761
+ turnEnded: true,
762
+ },
763
+ });
764
+ }
765
+ function cancelPendingAsks() {
766
+ asks.cancelAll();
767
+ }
768
+ /** The client's answer to an outbound `_gg/ask_user`, validated. */
769
+ function settleAsk(id, frame) {
770
+ const result = frame.result;
771
+ if (frame.error === undefined &&
772
+ result?.action === "answer" &&
773
+ result.answers &&
774
+ typeof result.answers === "object" &&
775
+ !Array.isArray(result.answers)) {
776
+ const answers = {};
777
+ for (const [key, value] of Object.entries(result.answers)) {
778
+ if (typeof value === "string")
779
+ answers[key] = value;
780
+ else if (Array.isArray(value)) {
781
+ answers[key] = value.filter((v) => typeof v === "string");
782
+ }
783
+ }
784
+ asks.settle(id, { action: "answer", answers });
785
+ return;
786
+ }
787
+ asks.settle(id, { action: "cancel" });
788
+ }
789
+ /**
790
+ * A goal starts when the user picks "Start the goal now", exactly as the
791
+ * desktop sidecar does. Never throws: a failed write leaves it briefing.
792
+ */
793
+ async function startGoalFrom(result, questions) {
794
+ const current = goal;
795
+ if (!current)
796
+ return;
797
+ const started = await startGoalOnAnswer(current.dir, current.record, result, new Date(), questions).catch(() => null);
798
+ if (started && goal === current) {
799
+ goal = { dir: current.dir, record: started };
800
+ notifyGoalState();
801
+ }
802
+ }
803
+ const askUserTool = createAskUserTool(async (request) => {
804
+ const result = await asks.park(request);
805
+ await startGoalFrom(result, request.questions);
806
+ return result;
807
+ });
532
808
  function notifyUpdate(update) {
533
809
  write({
534
810
  jsonrpc: "2.0",
@@ -578,8 +854,402 @@ export async function runAcpMode(options) {
578
854
  return;
579
855
  notifyUsage(target);
580
856
  notifySessionInfo(target);
857
+ notifyGoalState();
858
+ notifyPlanState();
859
+ notifyAutopilotState();
581
860
  }, 0);
582
861
  }
862
+ // ── GG extensions: plan review, autopilot state ──
863
+ function setPendingPlan(planFile, content) {
864
+ pendingPlan.planPath = planFile;
865
+ pendingPlan.content = content;
866
+ pendingPlan.generation++;
867
+ }
868
+ function clearPendingPlan() {
869
+ if (pendingPlan.planPath === null)
870
+ return;
871
+ pendingPlan.planPath = null;
872
+ pendingPlan.content = "";
873
+ pendingPlan.generation++;
874
+ }
875
+ /** `_gg/plan_state`: full state. `pending` only once offered to the human. */
876
+ function notifyPlanState() {
877
+ if (!sessionPlanReview || !session)
878
+ return;
879
+ const offered = pendingPlan.planPath !== null && pendingPlan.offeredGeneration === pendingPlan.generation;
880
+ write({
881
+ jsonrpc: "2.0",
882
+ method: "_gg/plan_state",
883
+ params: {
884
+ sessionId,
885
+ planMode: session.getPlanMode(),
886
+ pending: offered
887
+ ? {
888
+ planPath: pendingPlan.planPath,
889
+ content: pendingPlan.content,
890
+ generation: pendingPlan.generation,
891
+ }
892
+ : null,
893
+ },
894
+ });
895
+ }
896
+ /** Desktop offerPendingPlan: hand the plan over once everything settled. */
897
+ function offerPendingPlan() {
898
+ if (!sessionPlanReview || !session)
899
+ return;
900
+ const offer = shouldOfferPendingPlan({
901
+ planPath: pendingPlan.planPath,
902
+ generation: pendingPlan.generation,
903
+ offeredGeneration: pendingPlan.offeredGeneration,
904
+ running,
905
+ starting: false,
906
+ autopilotActive: autopilot.reviewing,
907
+ queued: session.listQueuedMessages?.().length ?? 0,
908
+ });
909
+ if (!offer)
910
+ return;
911
+ pendingPlan.offeredGeneration = pendingPlan.generation;
912
+ notifyPlanState();
913
+ }
914
+ /** `_gg/autopilot_state` for coding sessions of a client that speaks `_gg`. */
915
+ function notifyAutopilotState() {
916
+ if (!clientSpeaksGg || !session || goal)
917
+ return;
918
+ write({
919
+ jsonrpc: "2.0",
920
+ method: "_gg/autopilot_state",
921
+ params: { sessionId, enabled: autopilot.enabled, reviewing: autopilot.reviewing },
922
+ });
923
+ }
924
+ function setReviewing(on) {
925
+ if (autopilot.reviewing === on)
926
+ return;
927
+ autopilot.reviewing = on;
928
+ notifyAutopilotState();
929
+ }
930
+ /** `_gg/autopilot_event`, one per desktop `autopilot_*` frame. */
931
+ function notifyAutopilotEvent(params) {
932
+ write({ jsonrpc: "2.0", method: "_gg/autopilot_event", params: { sessionId, ...params } });
933
+ }
934
+ /** Desktop autopilot-runner's `persistAutopilotMarker`, same phases and moments. */
935
+ function persistMarker(target, phase, extra) {
936
+ void target.persistAutopilotMarker?.(phase, extra).catch(() => { });
937
+ }
938
+ function emitCycleFrame(event) {
939
+ switch (event.type) {
940
+ case "autopilot_done":
941
+ notifyAutopilotEvent({ phase: "done", ...event.data });
942
+ return;
943
+ case "autopilot_ignored":
944
+ notifyAutopilotEvent({ phase: "ignored" });
945
+ return;
946
+ case "autopilot_human":
947
+ notifyAutopilotEvent({ phase: "human", reason: event.data.reason });
948
+ return;
949
+ case "autopilot_capped":
950
+ notifyAutopilotEvent({ phase: "capped", round: event.data.rounds });
951
+ return;
952
+ case "autopilot_plan_accepted":
953
+ notifyAutopilotEvent({ phase: "plan_accepted" });
954
+ return;
955
+ }
956
+ }
957
+ async function workflowCommandSpecs() {
958
+ const custom = await loadCustomCommands(sessionCwd).catch(() => []);
959
+ return [
960
+ ...PROMPT_COMMANDS.map((c) => ({ name: c.name, aliases: c.aliases, prompt: c.prompt })),
961
+ ...custom.map((c) => ({ name: c.name, aliases: [], prompt: c.prompt })),
962
+ ];
963
+ }
964
+ function reviewer(target) {
965
+ kenReviewer ??= (options.createKenReviewer ?? defaultKenReviewer)(sessionCwd, () => {
966
+ const st = target.getState();
967
+ return { provider: st.provider, model: st.model };
968
+ });
969
+ return kenReviewer;
970
+ }
971
+ function defaultKenReviewer(cwd, buildModel) {
972
+ return createAcpKenReviewer({ cwd, buildModel });
973
+ }
974
+ /** One Ken review (work or plan); null on failure or when the cycle stopped. */
975
+ async function kenReview(target, plan) {
976
+ setReviewing(true);
977
+ notifyAutopilotEvent({ phase: "review_start" });
978
+ try {
979
+ const input = {
980
+ cwd: sessionCwd,
981
+ gitBranch: null,
982
+ messages: target.getMessages(),
983
+ verificationEvidence: target.getVerificationEvidence?.() ?? [],
984
+ verificationProblem: target.getVerificationProblem?.() ?? null,
985
+ originalRequest,
986
+ injectedPrompts: [...injectedPrompts],
987
+ workflowCommands: await workflowCommandSpecs(),
988
+ };
989
+ const digest = plan
990
+ ? buildKenAutopilotPlanContext({ ...input, planContent: plan.content })
991
+ : buildKenAutopilotContext(input);
992
+ const reply = await reviewer(target).review(digest);
993
+ if (cycle.stop || (plan && plan.generation !== pendingPlan.generation))
994
+ return null;
995
+ return parseAutopilotVerdict(reply);
996
+ }
997
+ catch (err) {
998
+ if (!cycle.stop)
999
+ notifyAutopilotEvent({ phase: "error", reason: formatErrorForDisplay(err) });
1000
+ return null;
1001
+ }
1002
+ finally {
1003
+ setReviewing(false);
1004
+ }
1005
+ }
1006
+ /**
1007
+ * AUTOPILOT HOOK — runs inside `session/prompt` after a non-cancelled build
1008
+ * turn: desktop runAutopilotCycle over the shared driveAutopilotCycle. The
1009
+ * prompt response waits for it; injected runs stream as normal turns.
1010
+ * Returns the stop result the prompt answers with.
1011
+ */
1012
+ async function afterBuildTurn(target, result, turn) {
1013
+ const messages = target.getMessages();
1014
+ const gate = shouldStartAutopilotCycle({
1015
+ enabled: autopilot.enabled && !goal,
1016
+ cancelled: false,
1017
+ planMode: target.getPlanMode(),
1018
+ planPending: pendingPlan.planPath !== null,
1019
+ workflowCommand: isWorkflowCommandText(turn.text, await workflowCommandSpecs()),
1020
+ assistantMessagesAdded: countAssistantMessages(messages) - turn.assistantBefore,
1021
+ mechanicalOnly: isMechanicalOnlyTurn(extractTurnToolCalls(messages, turn.messagesBefore)),
1022
+ });
1023
+ if (!gate.start)
1024
+ return result;
1025
+ originalRequest = turn.text;
1026
+ cycle.active = true;
1027
+ cycle.stop = null;
1028
+ let last = result;
1029
+ let planGenAtReview = -1;
1030
+ const isStopped = () => cycle.stop !== null || target !== session;
1031
+ const injectedRun = async (prompt, before) => {
1032
+ last = await runTurn(async (t) => {
1033
+ await before?.();
1034
+ await t.prompt(prompt, AUTOMATION_PROVENANCE);
1035
+ });
1036
+ if (cancelled)
1037
+ cycle.stop ??= "cancelled";
1038
+ };
1039
+ try {
1040
+ await driveAutopilotCycle({
1041
+ maxRounds: pendingPlan.planPath !== null ? MAX_AUTOPILOT_ROUNDS + 2 : MAX_AUTOPILOT_ROUNDS,
1042
+ isCancelled: isStopped,
1043
+ verificationProblem: () => target.getVerificationProblem?.() ?? null,
1044
+ isPlanMode: () => target.getPlanMode(),
1045
+ planPending: () => pendingPlan.planPath !== null,
1046
+ resetReviewer: async () => {
1047
+ await kenReviewer?.reset();
1048
+ },
1049
+ review: () => kenReview(target, null),
1050
+ reviewPlan: () => {
1051
+ planGenAtReview = pendingPlan.generation;
1052
+ return kenReview(target, {
1053
+ content: pendingPlan.planPath
1054
+ ? readPlanFile(pendingPlan.planPath) || pendingPlan.content
1055
+ : pendingPlan.content,
1056
+ generation: planGenAtReview,
1057
+ });
1058
+ },
1059
+ acceptPlan: async (reason) => {
1060
+ if (pendingPlan.planPath === null || pendingPlan.generation !== planGenAtReview) {
1061
+ return false;
1062
+ }
1063
+ notifyAutopilotEvent({ phase: "plan_accepted" });
1064
+ try {
1065
+ await acceptPendingPlan(target);
1066
+ }
1067
+ catch (err) {
1068
+ notifyAutopilotEvent({ phase: "error", reason: formatErrorForDisplay(err) });
1069
+ return false;
1070
+ }
1071
+ // Persisted into the NEW session (as the desktop does) so a reload
1072
+ // shows the marker.
1073
+ persistMarker(target, "plan_approved", { reason });
1074
+ injectedPrompts = [];
1075
+ return true;
1076
+ },
1077
+ runImplement: async () => {
1078
+ const framed = frameAutopilotInjection(IMPLEMENT_PLAN_PROMPT);
1079
+ injectedPrompts.push(framed);
1080
+ await injectedRun(framed);
1081
+ },
1082
+ onInjected: (body, round) => {
1083
+ clearPendingPlan();
1084
+ injectedPrompts.push(frameAutopilotInjection(body));
1085
+ notifyAutopilotEvent({ phase: "prompted", body, round });
1086
+ persistMarker(target, "prompted", { body });
1087
+ },
1088
+ runPrompt: (body, opts) => injectedRun(frameAutopilotInjection(body), opts?.planRevision ? () => reenterPlanModeForRevision(target) : undefined),
1089
+ emit: (event) => {
1090
+ // plan_accepted is sent by acceptPlan, ahead of _gg/session_reset.
1091
+ if (event.type !== "autopilot_plan_accepted")
1092
+ emitCycleFrame(event);
1093
+ if (event.type === "autopilot_done")
1094
+ persistMarker(target, "done", event.data);
1095
+ else if (event.type === "autopilot_human") {
1096
+ persistMarker(target, "human", { reason: event.data.reason });
1097
+ }
1098
+ else if (event.type === "autopilot_capped")
1099
+ persistMarker(target, "capped");
1100
+ },
1101
+ });
1102
+ }
1103
+ finally {
1104
+ const stop = cycle.stop;
1105
+ cycle.active = false;
1106
+ cycle.stop = null;
1107
+ setReviewing(false);
1108
+ // A queued message ended the cycle: hand it back as the next prompt.
1109
+ if (stop === "ignored") {
1110
+ notifyAutopilotEvent({ phase: "ignored" });
1111
+ if (target === session)
1112
+ returnQueue(true);
1113
+ }
1114
+ if (stop === "cancelled")
1115
+ last = { stopReason: "cancelled" };
1116
+ }
1117
+ return last;
1118
+ }
1119
+ /** mtime of the project's checklist file, or null when absent. */
1120
+ function checklistStamp() {
1121
+ try {
1122
+ return statSync(path.join(sessionCwd, CHECKLIST_FILE)).mtimeMs;
1123
+ }
1124
+ catch {
1125
+ return null;
1126
+ }
1127
+ }
1128
+ /** A build turn plus the settle steps (autopilot hook, plan offer, checklist). */
1129
+ async function runBuildTurn(text, work) {
1130
+ const target = session;
1131
+ const before = checklistStamp();
1132
+ const messagesBefore = target?.getMessages() ?? [];
1133
+ const turn = {
1134
+ messagesBefore: messagesBefore.length,
1135
+ assistantBefore: countAssistantMessages(messagesBefore),
1136
+ text,
1137
+ };
1138
+ let result = await runTurn(work);
1139
+ if (target && target === session) {
1140
+ if (!cancelled)
1141
+ result = await afterBuildTurn(target, result, turn);
1142
+ offerPendingPlan();
1143
+ if (checklistStamp() !== before) {
1144
+ write({ jsonrpc: "2.0", method: "_gg/checklist_changed", params: { sessionId } });
1145
+ }
1146
+ }
1147
+ return result;
1148
+ }
1149
+ /** `_meta.gg.planDecision` on a `session/prompt`. */
1150
+ function planDecisionOf(params) {
1151
+ const raw = params?._meta?.gg
1152
+ ?.planDecision;
1153
+ if (!raw || typeof raw !== "object")
1154
+ return null;
1155
+ const { action, feedback, generation } = raw;
1156
+ if (action !== "accept" && action !== "feedback" && action !== "reject") {
1157
+ throw new InvalidParams("planDecision.action must be accept, feedback or reject.");
1158
+ }
1159
+ if (typeof generation !== "number") {
1160
+ throw new InvalidParams("planDecision.generation must be a number.");
1161
+ }
1162
+ return { action, feedback: typeof feedback === "string" ? feedback : "", generation };
1163
+ }
1164
+ /**
1165
+ * Desktop /plan/accept: fresh context with the plan baked in, ACP sessionId
1166
+ * kept stable, `_gg/session_reset` then the ACP `plan`. A session without
1167
+ * `newSession` keeps its context and sends no reset.
1168
+ */
1169
+ async function acceptPendingPlan(target) {
1170
+ const planFile = pendingPlan.planPath ?? undefined;
1171
+ clearPendingPlan();
1172
+ if (target.newSession) {
1173
+ await target.newSession(true);
1174
+ titleAnnounced = false;
1175
+ }
1176
+ await target.setApprovedPlan(planFile);
1177
+ const steps = planFile ? extractPlanSteps(readPlanFile(planFile)) : [];
1178
+ if (target.newSession) {
1179
+ write({
1180
+ jsonrpc: "2.0",
1181
+ method: "_gg/session_reset",
1182
+ params: { sessionId, agentSessionId: target.getState().sessionId, planTotal: steps.length },
1183
+ });
1184
+ }
1185
+ notifyPlanState();
1186
+ if (planFile)
1187
+ adoptPlan(planFile);
1188
+ }
1189
+ /** Desktop reenterPlanModeForRevision. */
1190
+ async function reenterPlanModeForRevision(target) {
1191
+ if (target.getPlanMode())
1192
+ return;
1193
+ await target.setPlanMode(true);
1194
+ notifyModeChange(MODE_PLAN);
1195
+ }
1196
+ async function handlePlanDecision(decision) {
1197
+ const target = session;
1198
+ if (!target)
1199
+ throw new InvalidParams("No session. Call session/new first.");
1200
+ if (pendingPlan.planPath === null || decision.generation !== pendingPlan.generation) {
1201
+ throw new InvalidParams("That plan is no longer pending.");
1202
+ }
1203
+ if (decision.action === "reject") {
1204
+ clearPendingPlan();
1205
+ if (target.getPlanMode()) {
1206
+ await target.setPlanMode(false);
1207
+ notifyModeChange(MODE_DEFAULT);
1208
+ }
1209
+ notifyPlanState();
1210
+ return { stopReason: "end_turn" };
1211
+ }
1212
+ if (decision.action === "accept") {
1213
+ await acceptPendingPlan(target);
1214
+ return runBuildTurn(IMPLEMENT_PLAN_PROMPT, (t) => t.prompt(IMPLEMENT_PLAN_PROMPT, {
1215
+ source: "runtime",
1216
+ kind: "notification",
1217
+ visibility: "hidden",
1218
+ }));
1219
+ }
1220
+ const feedback = decision.feedback.trim();
1221
+ if (!feedback)
1222
+ throw new InvalidParams("planDecision feedback needs text.");
1223
+ clearPendingPlan();
1224
+ await reenterPlanModeForRevision(target);
1225
+ notifyPlanState();
1226
+ notifyUpdate({
1227
+ sessionUpdate: "user_message_chunk",
1228
+ content: { type: "text", text: feedback },
1229
+ });
1230
+ return runBuildTurn(feedback, (t) => t.prompt(buildUserPlanFeedbackPrompt(feedback)));
1231
+ }
1232
+ async function handleAutopilotSet(params) {
1233
+ activeSession(params, "_gg/autopilot/set");
1234
+ const enabled = params.enabled;
1235
+ if (typeof enabled !== "boolean")
1236
+ throw new InvalidParams("_gg/autopilot/set needs enabled.");
1237
+ if (goal)
1238
+ throw new InvalidParams("Autopilot is for coding sessions.");
1239
+ await saveAutopilot(sessionCwd, enabled);
1240
+ if (autopilot.enabled !== enabled) {
1241
+ autopilot.enabled = enabled;
1242
+ notifyAutopilotState();
1243
+ }
1244
+ return { enabled };
1245
+ }
1246
+ async function handleChecklist(params) {
1247
+ activeSession(params, "_gg/checklist");
1248
+ const snapshot = await readChecklistSnapshot(sessionCwd, new Date());
1249
+ if (!snapshot.ok)
1250
+ throw new InvalidParams(snapshot.error);
1251
+ return snapshot.value;
1252
+ }
583
1253
  /**
584
1254
  * The id chunks of the current agent message share, minted on demand.
585
1255
  */
@@ -689,7 +1359,7 @@ export async function runAcpMode(options) {
689
1359
  // announce its own commands.
690
1360
  if (session !== target || sessionId !== forSession)
691
1361
  return;
692
- void availableCommands(target, options.cwd)
1362
+ void availableCommands(target, sessionCwd)
693
1363
  .then((commands) => {
694
1364
  if (session !== target || sessionId !== forSession)
695
1365
  return;
@@ -770,7 +1440,7 @@ export async function runAcpMode(options) {
770
1440
  // A tool call ends the message it interrupted; whatever the agent says
771
1441
  // afterwards is a new one.
772
1442
  endMessage();
773
- const locations = toolLocations(args, options.cwd);
1443
+ const locations = toolLocations(args, sessionCwd);
774
1444
  // Snapshot BEFORE the tool runs. This handler is synchronous and the
775
1445
  // tool starts writing immediately after it, which is the only window
776
1446
  // where the file still holds its pre-edit contents.
@@ -838,6 +1508,22 @@ export async function runAcpMode(options) {
838
1508
  bus.on("compaction_end", () => {
839
1509
  notifyUsage(target);
840
1510
  }),
1511
+ // Queued steering was folded into the run: tell the client which ones,
1512
+ // so it can log them as user turns at this point.
1513
+ bus.on("queue_drained", () => {
1514
+ if (target !== session)
1515
+ return;
1516
+ const current = queueSnapshot();
1517
+ const drained = knownQueue.filter((m) => !current.some((c) => c.id === m.id));
1518
+ if (drained.length > 0)
1519
+ notifyQueue({ drained });
1520
+ else
1521
+ knownQueue = current;
1522
+ }),
1523
+ bus.on("subagent_state", () => {
1524
+ if (target === session)
1525
+ notifyGoalStateSoon();
1526
+ }),
841
1527
  ];
842
1528
  }
843
1529
  function unwireAll() {
@@ -852,6 +1538,7 @@ export async function runAcpMode(options) {
852
1538
  */
853
1539
  function resetSessionState() {
854
1540
  diffSnapshots.clear();
1541
+ injectedPrompts = [];
855
1542
  planPath = undefined;
856
1543
  planSteps = [];
857
1544
  completedSteps.clear();
@@ -859,15 +1546,30 @@ export async function runAcpMode(options) {
859
1546
  titleAnnounced = false;
860
1547
  currentMessageId = null;
861
1548
  messageSeq = 0;
1549
+ knownQueue = [];
1550
+ pendingPlan.planPath = null;
1551
+ pendingPlan.content = "";
1552
+ pendingPlan.generation++;
1553
+ pendingPlan.offeredGeneration = -1;
1554
+ if (goalStateTimer)
1555
+ clearTimeout(goalStateTimer);
1556
+ goalStateTimer = null;
862
1557
  }
863
1558
  async function disposeSession() {
864
1559
  if (!session)
865
1560
  return;
1561
+ // Questions belong to the session that asked them.
1562
+ asks.cancelAll();
866
1563
  unwireAll();
867
1564
  resetSessionState();
1565
+ cycle.stop ??= cycle.active ? "cancelled" : null;
1566
+ const ken = kenReviewer;
1567
+ kenReviewer = null;
1568
+ await ken?.dispose().catch(() => undefined);
868
1569
  const previous = session;
869
1570
  session = null;
870
1571
  sessionId = "";
1572
+ goal = null;
871
1573
  await previous.dispose();
872
1574
  }
873
1575
  /**
@@ -885,8 +1587,17 @@ export async function runAcpMode(options) {
885
1587
  onEnterPlan: async () => {
886
1588
  await session?.setPlanMode(true);
887
1589
  notifyModeChange(MODE_PLAN);
1590
+ notifyPlanState();
888
1591
  },
889
1592
  onExitPlan: async (approvedPath) => {
1593
+ if (sessionPlanReview) {
1594
+ // Desktop onExitPlan: park the plan; it is offered once the run settles.
1595
+ await session?.setPlanMode(false);
1596
+ notifyModeChange(MODE_DEFAULT);
1597
+ setPendingPlan(approvedPath, readPlanFile(approvedPath));
1598
+ notifyPlanState();
1599
+ return PLAN_SUBMITTED_FOR_REVIEW;
1600
+ }
890
1601
  await session?.setPlanMode(false);
891
1602
  await session?.setApprovedPlan(approvedPath);
892
1603
  notifyModeChange(MODE_DEFAULT);
@@ -900,36 +1611,67 @@ export async function runAcpMode(options) {
900
1611
  },
901
1612
  };
902
1613
  const createSession = options.createSession ??
903
- ((signal, hooks) => new AgentSession({
904
- provider: options.provider,
905
- model: options.model,
906
- cwd: options.cwd,
907
- baseUrl: options.baseUrl,
908
- systemPrompt: options.systemPrompt,
909
- thinkingLevel: options.thinkingLevel,
910
- // MCP connect (spawning stdio servers, HTTP handshakes) takes seconds
911
- // and would otherwise sit on the critical path of session/new and
912
- // session/load. The desktop sidecar already ships this path: the tool
913
- // catalog is seeded from the disk cache so tools are visible
914
- // immediately, and live connections promote in the background. A phone
915
- // client gets its session in milliseconds and the same tools a moment
916
- // later.
917
- backgroundMcpConnect: true,
918
- onEnterPlan: hooks.onEnterPlan,
919
- onExitPlan: hooks.onExitPlan,
920
- signal,
921
- }));
1614
+ ((signal, hooks, context) => {
1615
+ const additionalTools = context.askUserTool ? [context.askUserTool] : [];
1616
+ if (context.goal) {
1617
+ // A goal folder's session is GG Goals, the coordinator, as on the desktop.
1618
+ return createGoalsAgentSession({
1619
+ provider: options.provider,
1620
+ model: options.model,
1621
+ cwd: context.cwd,
1622
+ baseUrl: options.baseUrl,
1623
+ thinkingLevel: options.thinkingLevel,
1624
+ backgroundMcpConnect: true,
1625
+ signal,
1626
+ goal: context.goal,
1627
+ extras: context.goalExtras,
1628
+ isActive: context.isGoalActive,
1629
+ getChildModel: context.getChildModel,
1630
+ additionalTools,
1631
+ });
1632
+ }
1633
+ return new AgentSession({
1634
+ provider: options.provider,
1635
+ model: options.model,
1636
+ cwd: context.cwd,
1637
+ baseUrl: options.baseUrl,
1638
+ systemPrompt: options.systemPrompt,
1639
+ thinkingLevel: options.thinkingLevel,
1640
+ // MCP connect (spawning stdio servers, HTTP handshakes) takes seconds
1641
+ // and would otherwise sit on the critical path of session/new and
1642
+ // session/load. The desktop sidecar already ships this path: the tool
1643
+ // catalog is seeded from the disk cache so tools are visible
1644
+ // immediately, and live connections promote in the background. A phone
1645
+ // client gets its session in milliseconds and the same tools a moment
1646
+ // later.
1647
+ backgroundMcpConnect: true,
1648
+ onEnterPlan: hooks.onEnterPlan,
1649
+ onExitPlan: hooks.onExitPlan,
1650
+ ...(context.planReview ? { planSubmissionGate: true } : {}),
1651
+ ...(additionalTools.length > 0 ? { additionalTools } : {}),
1652
+ signal,
1653
+ });
1654
+ });
922
1655
  // ── Method handlers ──────────────────────────────────────
923
- function handleInitialize() {
1656
+ function handleInitialize(params) {
1657
+ const gg = params?.clientCapabilities?._meta?.gg;
1658
+ clientAsksUser = gg?.askUser === true;
1659
+ clientPlanReview = gg?.planReview === true;
1660
+ clientSpeaksGg = gg !== undefined && gg !== null;
924
1661
  return {
925
1662
  protocolVersion: ACP_PROTOCOL_VERSION,
926
1663
  agentCapabilities: {
927
1664
  loadSession: true,
928
- promptCapabilities: { image: false, audio: false, embeddedContext: false },
1665
+ promptCapabilities: { image: true, audio: false, embeddedContext: false },
929
1666
  mcpCapabilities: { http: false, sse: false, acp: false },
930
1667
  // `{}` is how ACP says "supported" for a capability with no options of
931
1668
  // its own. Omitting the key means unsupported, so this is not cosmetic.
932
1669
  sessionCapabilities: { list: {}, resume: {}, close: {}, delete: {} },
1670
+ // GG's own extensions (custom `_gg/*` methods). Clients that don't
1671
+ // read this see standard ACP only.
1672
+ _meta: {
1673
+ gg: { queue: 1, askUser: 1, goals: 1, planReview: 1, autopilot: 1, checklist: 1 },
1674
+ },
933
1675
  },
934
1676
  authMethods: [],
935
1677
  agentInfo: { name: "ggcoder", title: "GG Coder", version: options.version },
@@ -941,25 +1683,59 @@ export async function runAcpMode(options) {
941
1683
  * `session/new` and `session/load` differ only in whether history is
942
1684
  * restored first, so they share this path rather than drifting apart.
943
1685
  */
944
- async function startSession(restorePath) {
1686
+ async function startSession(restorePath, requestedCwd) {
945
1687
  // Replacing an in-flight session would strand the prompt that is running
946
1688
  // on it, so the old one is stopped first, deliberately and visibly.
947
1689
  await disposeSession();
1690
+ // A session whose cwd is a goal folder is that goal's coordinator.
1691
+ // Everything else keeps the directory the agent was started in.
1692
+ const record = isGoalDir(goalsRoot(agentDir), requestedCwd)
1693
+ ? await readGoal(requestedCwd)
1694
+ : null;
1695
+ goal = record ? { dir: requestedCwd, record } : null;
1696
+ sessionCwd = goal ? goal.dir : options.cwd;
1697
+ const goalExtras = goal ? await loadGoalBriefExtras(goal) : undefined;
948
1698
  abort = new AbortController();
949
- const created = createSession(abort.signal, planHooks);
1699
+ // Plan review is for coding sessions only; GG Goals has no plan mode.
1700
+ sessionPlanReview = clientPlanReview && !goal;
1701
+ autopilot.enabled = goal ? false : await loadAutopilot(sessionCwd).catch(() => false);
1702
+ autopilot.reviewing = false;
1703
+ const created = createSession(abort.signal, planHooks, {
1704
+ ...(sessionPlanReview ? { planReview: true } : {}),
1705
+ cwd: sessionCwd,
1706
+ goal,
1707
+ ...(goalExtras ? { goalExtras } : {}),
1708
+ isGoalActive: () => Boolean(goal?.record.startedAt),
1709
+ getChildModel: () => goalCritterPick(goal?.record),
1710
+ ...(clientAsksUser ? { askUserTool } : {}),
1711
+ });
950
1712
  await created.initialize();
951
1713
  if (restorePath)
952
1714
  await created.loadSession(restorePath);
1715
+ // A goal's attached folders join its workspace so the coordinator can read
1716
+ // them. A resumed session may already carry them.
1717
+ if (goal && created.addDirectory) {
1718
+ for (const item of goal.record.context) {
1719
+ if (item.kind !== "folder")
1720
+ continue;
1721
+ await created.addDirectory(item.path).catch(() => undefined);
1722
+ }
1723
+ }
953
1724
  session = created;
954
1725
  sessionId = created.getState().sessionId;
955
1726
  wire(created);
956
1727
  return created;
957
1728
  }
958
- async function handleNewSession() {
959
- const created = await startSession();
1729
+ async function handleNewSession(params) {
1730
+ const created = await startSession(undefined, requestCwd(params));
960
1731
  notifyAvailableCommands(created);
961
1732
  announceSessionSoon(created);
962
- return { sessionId, configOptions: configOptionsFor(created), modes: sessionModes(created) };
1733
+ return {
1734
+ sessionId,
1735
+ configOptions: configOptionsFor(created),
1736
+ modes: sessionModes(created),
1737
+ ...goalMeta(created),
1738
+ };
963
1739
  }
964
1740
  /** The directory a request is about, defaulting to the one we were started in. */
965
1741
  function requestCwd(params) {
@@ -1016,7 +1792,7 @@ export async function runAcpMode(options) {
1016
1792
  const sessionPath = await findSessionById(requested, requestCwd(params));
1017
1793
  if (!sessionPath)
1018
1794
  throw new InvalidParams(`Unknown session '${requested}'.`);
1019
- const restored = await startSession(sessionPath);
1795
+ const restored = await startSession(sessionPath, requestCwd(params));
1020
1796
  // Display history is reconstructed separately. The live AgentSession above
1021
1797
  // deliberately keeps only the canonical newest checkpoint as model context.
1022
1798
  const checkpoints = await loadSessionCheckpointChain(sessionPath);
@@ -1025,11 +1801,26 @@ export async function runAcpMode(options) {
1025
1801
  // adopt a different internal one, and answering with that would leave the
1026
1802
  // client addressing a session it never heard of.
1027
1803
  sessionId = requested;
1028
- for (const update of historyUpdates(displayMessages))
1029
- notifyUpdate(update);
1804
+ // Ken's verdict markers ride between the replayed frames as
1805
+ // `_gg/autopilot_event` with `history: true`, only for `_gg` clients.
1806
+ const markers = clientSpeaksGg ? (restored.getAutopilotMarkers?.() ?? []) : [];
1807
+ for (const frame of historyFrames(displayMessages, markers)) {
1808
+ if ("update" in frame)
1809
+ notifyUpdate(frame.update);
1810
+ else
1811
+ write({
1812
+ jsonrpc: "2.0",
1813
+ method: "_gg/autopilot_event",
1814
+ params: { sessionId, ...frame.autopilot },
1815
+ });
1816
+ }
1030
1817
  notifyAvailableCommands(restored);
1031
1818
  announceSessionSoon(restored);
1032
- return { configOptions: configOptionsFor(restored), modes: sessionModes(restored) };
1819
+ return {
1820
+ configOptions: configOptionsFor(restored),
1821
+ modes: sessionModes(restored),
1822
+ ...goalMeta(restored),
1823
+ };
1033
1824
  }
1034
1825
  /**
1035
1826
  * Abort the running turn and WAIT for it to unwind.
@@ -1065,12 +1856,16 @@ export async function runAcpMode(options) {
1065
1856
  const sessionPath = await findSessionById(requested, requestCwd(params));
1066
1857
  if (!sessionPath)
1067
1858
  throw new InvalidParams(`Unknown session '${requested}'.`);
1068
- const restored = await startSession(sessionPath);
1859
+ const restored = await startSession(sessionPath, requestCwd(params));
1069
1860
  // As in session/load: the client keeps addressing the id it asked for.
1070
1861
  sessionId = requested;
1071
1862
  notifyAvailableCommands(restored);
1072
1863
  announceSessionSoon(restored);
1073
- return { configOptions: configOptionsFor(restored), modes: sessionModes(restored) };
1864
+ return {
1865
+ configOptions: configOptionsFor(restored),
1866
+ modes: sessionModes(restored),
1867
+ ...goalMeta(restored),
1868
+ };
1074
1869
  }
1075
1870
  /**
1076
1871
  * Close the active session, cancelling whatever it is doing.
@@ -1199,20 +1994,27 @@ export async function runAcpMode(options) {
1199
1994
  .join("\n")
1200
1995
  .trim();
1201
1996
  }
1202
- async function handlePrompt(params) {
1203
- if (!session)
1997
+ /** `_meta.gg.goalTurn` on a `session/prompt`: GG Goals' kickoff or resume. */
1998
+ function goalTurnOf(params) {
1999
+ const turn = params?._meta?.gg
2000
+ ?.goalTurn;
2001
+ return turn === "kickoff" || turn === "resume" ? turn : undefined;
2002
+ }
2003
+ /**
2004
+ * Run one turn with the ACP lifecycle around it: one at a time, cancel
2005
+ * resolves as `cancelled`, leftover queued steering is handed back.
2006
+ */
2007
+ async function runTurn(work) {
2008
+ const target = session;
2009
+ if (!target)
1204
2010
  throw new InvalidParams("No session. Call session/new first.");
1205
- if (running)
1206
- throw new InvalidParams("A prompt is already running in this session.");
1207
- const text = promptText(params);
1208
- if (!text)
1209
- throw new InvalidParams("prompt must contain at least one non-empty text block.");
1210
2011
  running = true;
1211
2012
  cancelled = false;
1212
2013
  truncation = undefined;
1213
2014
  hitMaxTurns = false;
2015
+ knownQueue = [];
1214
2016
  try {
1215
- await session.prompt(text);
2017
+ await work(target);
1216
2018
  }
1217
2019
  catch (err) {
1218
2020
  // A cancel aborts the loop, which surfaces here. The spec requires the
@@ -1231,13 +2033,90 @@ export async function runAcpMode(options) {
1231
2033
  // Cleared here rather than on `turn_end`, which fires before that turn's
1232
2034
  // tools execute and would discard snapshots still in use.
1233
2035
  diffSnapshots.clear();
2036
+ // Steering that never reached a boundary goes back to the client as the
2037
+ // next prompt; ggcoder never starts a turn by itself.
2038
+ if (!cancelled && target === session)
2039
+ returnQueue(true);
1234
2040
  }
1235
2041
  return { stopReason: cancelled ? "cancelled" : stopReasonFor(truncation, hitMaxTurns) };
1236
2042
  }
2043
+ async function handlePrompt(params) {
2044
+ if (!session)
2045
+ throw new InvalidParams("No session. Call session/new first.");
2046
+ if (running || cycle.active) {
2047
+ throw new InvalidParams("A prompt is already running in this session.");
2048
+ }
2049
+ const goalTurn = goalTurnOf(params);
2050
+ if (goalTurn)
2051
+ return runGoalTurn(goalTurn);
2052
+ const decision = planDecisionOf(params);
2053
+ if (decision)
2054
+ return handlePlanDecision(decision);
2055
+ const text = promptText(params);
2056
+ const images = acpImageAttachments(params?.prompt);
2057
+ if (!text && images.length === 0) {
2058
+ throw new InvalidParams("prompt must contain at least one non-empty text block.");
2059
+ }
2060
+ return runBuildTurn(text, async (target) => {
2061
+ if (images.length > 0 && target.promptWithAttachments) {
2062
+ // Same path as the desktop: saved under .gg/uploads, sent as native blocks.
2063
+ const prepared = await prepareAttachments(sessionCwd, images);
2064
+ await target.promptWithAttachments(text, prepared);
2065
+ return;
2066
+ }
2067
+ if (!text)
2068
+ return;
2069
+ await target.prompt(text);
2070
+ });
2071
+ }
2072
+ /**
2073
+ * GG Goals' kickoff / resume as an ordinary turn, with the hidden runtime
2074
+ * provenance the desktop uses. Nothing to do answers `end_turn` at once.
2075
+ */
2076
+ async function runGoalTurn(kind) {
2077
+ const current = goal;
2078
+ if (!current || !session)
2079
+ return { stopReason: "end_turn" };
2080
+ if (kind === "kickoff" && !goalNeedsKickoff(current.record, session.getMessages())) {
2081
+ return { stopReason: "end_turn" };
2082
+ }
2083
+ return runTurn(async (target) => {
2084
+ if (kind === "kickoff") {
2085
+ const updated = await markGoalKickoff(current.dir, new Date());
2086
+ if (!updated)
2087
+ return;
2088
+ if (goal === current)
2089
+ goal = { dir: current.dir, record: updated };
2090
+ await target.prompt(goalKickoffPrompt(updated), GOAL_RUNTIME_PROVENANCE);
2091
+ return;
2092
+ }
2093
+ const wasPaused = Boolean(current.record.pausedAt);
2094
+ const record = wasPaused
2095
+ ? ((await setGoalPaused(current.dir, false, new Date())) ?? current.record)
2096
+ : current.record;
2097
+ if (goal === current)
2098
+ goal = { dir: current.dir, record };
2099
+ if (wasPaused)
2100
+ notifyGoalState();
2101
+ const cutOff = wasPaused ||
2102
+ goalWasInterrupted(record, target.getMessages(), target.listSubAgents?.() ?? []);
2103
+ if (!cutOff)
2104
+ return;
2105
+ await target.prompt(goalResumePrompt(record), GOAL_RUNTIME_PROVENANCE);
2106
+ });
2107
+ }
1237
2108
  function handleCancel() {
2109
+ // A question parked on the user is not released by the signal.
2110
+ cancelPendingAsks();
2111
+ if (cycle.active) {
2112
+ cycle.stop = "cancelled";
2113
+ kenReviewer?.abort();
2114
+ }
1238
2115
  if (!running || !session)
1239
2116
  return;
1240
2117
  cancelled = true;
2118
+ // Whatever was still queued is handed back, not run.
2119
+ returnQueue(false);
1241
2120
  abort.abort();
1242
2121
  // `AgentSession` keeps the construction signal for future turns. Re-arm it
1243
2122
  // immediately after aborting this turn, or every later prompt sees an
@@ -1245,12 +2124,136 @@ export async function runAcpMode(options) {
1245
2124
  abort = new AbortController();
1246
2125
  session.setSignal(abort.signal);
1247
2126
  }
2127
+ // ── GG extensions: queue ──
2128
+ /** The active session a `_gg/*` request names, validated. */
2129
+ function activeSession(params, method) {
2130
+ const requested = requestedSessionId(params, method);
2131
+ if (!session || requested !== sessionId) {
2132
+ throw new InvalidParams(`Session '${requested}' is not active.`);
2133
+ }
2134
+ return session;
2135
+ }
2136
+ function handleQueue(params) {
2137
+ const target = activeSession(params, "_gg/queue");
2138
+ const text = params.text;
2139
+ if (typeof text !== "string" || !text.trim()) {
2140
+ throw new InvalidParams("_gg/queue requires non-empty text.");
2141
+ }
2142
+ if (cycle.active && !running && target.queueMessage && target.listQueuedMessages) {
2143
+ // Desktop: a user message during Ken's review ends the cycle (ignored)
2144
+ // and runs next; here it is handed back with turnEnded when the cycle ends.
2145
+ cycle.stop ??= "ignored";
2146
+ kenReviewer?.abort();
2147
+ }
2148
+ else if (!running || !target.queueMessage || !target.listQueuedMessages) {
2149
+ throw new InvalidParams("No prompt is running. Send session/prompt instead.");
2150
+ }
2151
+ target.queueMessage(text);
2152
+ const queued = target.listQueuedMessages();
2153
+ knownQueue = queued;
2154
+ return { id: queued.at(-1)?.id ?? "", queued };
2155
+ }
2156
+ function handleQueueCancel(params) {
2157
+ const target = activeSession(params, "_gg/queue/cancel");
2158
+ const id = params.id;
2159
+ if (typeof id !== "string" || !id)
2160
+ throw new InvalidParams("_gg/queue/cancel requires an id.");
2161
+ // false is the normal race (it already drained), not an error.
2162
+ const cancelled = target.cancelQueuedMessage?.(id) ?? false;
2163
+ const queued = queueSnapshot();
2164
+ knownQueue = queued;
2165
+ return { cancelled, queued };
2166
+ }
2167
+ // ── GG extensions: goals ──
2168
+ async function handleGoalsCreate(params) {
2169
+ const created = await createAcpGoal(agentDir, params);
2170
+ if (!created.ok)
2171
+ throw new InvalidParams(created.error);
2172
+ return { goal: created.value };
2173
+ }
2174
+ async function handleGoalsDelete(params) {
2175
+ const id = params?.id;
2176
+ if (typeof id !== "string" || !id)
2177
+ throw new InvalidParams("Missing goal id");
2178
+ const ownsIt = goal?.record.id === id;
2179
+ if (ownsIt && running)
2180
+ throw new InvalidParams("This goal is still working. Stop it first.");
2181
+ const deleted = await deleteGoal(goalsRoot(agentDir), path.join(agentDir, "sessions"), id);
2182
+ if (!deleted.ok)
2183
+ throw new InvalidParams(deleted.error);
2184
+ if (ownsIt)
2185
+ goal = null;
2186
+ return { ok: true };
2187
+ }
2188
+ /** The goal the named session coordinates. */
2189
+ function activeGoal(params, method) {
2190
+ const target = activeSession(params, method);
2191
+ if (!goal)
2192
+ throw new InvalidParams("This session is not a goal.");
2193
+ return { target, current: goal };
2194
+ }
2195
+ async function handleGoalPause(params) {
2196
+ const { current } = activeGoal(params, "_gg/goal/pause");
2197
+ const updated = await setGoalPaused(current.dir, true, new Date());
2198
+ if (!updated)
2199
+ throw new InvalidParams("Unknown goal");
2200
+ if (goal === current)
2201
+ goal = { dir: current.dir, record: updated };
2202
+ // Stops the run; its workers stop through the same signal.
2203
+ handleCancel();
2204
+ notifyGoalState();
2205
+ return { paused: true };
2206
+ }
2207
+ async function handleGoalUpdate(params) {
2208
+ const { target, current } = activeGoal(params, "_gg/goal/update");
2209
+ if (running)
2210
+ throw new InvalidParams("This goal is still working. Pause it first.");
2211
+ const { context, instructions } = params;
2212
+ const updated = await updateGoal(current.dir, {
2213
+ ...(Array.isArray(context)
2214
+ ? { context: context.filter((p) => typeof p === "string") }
2215
+ : {}),
2216
+ ...(typeof instructions === "string" ? { instructions } : {}),
2217
+ });
2218
+ if (!updated.ok)
2219
+ throw new InvalidParams(updated.error);
2220
+ if (goal === current)
2221
+ goal = { dir: current.dir, record: updated.value };
2222
+ // GG Goals' brief is fixed for the session (prompt cache): it hears the new
2223
+ // settings at its next turn, and they're in the brief from the next session.
2224
+ target.notify?.("goal-settings", goalSettingsNote(updated.value));
2225
+ for (const item of updated.value.context) {
2226
+ if (item.kind !== "folder")
2227
+ continue;
2228
+ await target.addDirectory?.(item.path).catch(() => undefined);
2229
+ }
2230
+ return { context: updated.value.context, instructions: updated.value.instructions ?? "" };
2231
+ }
2232
+ async function handleGoalCritterModel(params) {
2233
+ const { current } = activeGoal(params, "_gg/goal/critter_model");
2234
+ const raw = params.model;
2235
+ const requested = typeof raw === "string" && raw.trim() ? raw.trim() : null;
2236
+ let pick = null;
2237
+ if (requested !== null) {
2238
+ const target = getModel(requested);
2239
+ if (!target)
2240
+ throw new InvalidParams(`unknown model: ${requested}`);
2241
+ pick = { provider: target.provider, model: target.id };
2242
+ }
2243
+ const updated = await setGoalCritterModel(current.dir, pick);
2244
+ if (!updated)
2245
+ throw new InvalidParams("Unknown goal");
2246
+ if (goal === current)
2247
+ goal = { dir: current.dir, record: updated };
2248
+ notifyGoalState();
2249
+ return { critterModel: goalCritterPick(updated)?.model ?? null };
2250
+ }
1248
2251
  async function dispatch(method, params) {
1249
2252
  switch (method) {
1250
2253
  case "initialize":
1251
- return handleInitialize();
2254
+ return handleInitialize(params);
1252
2255
  case "session/new":
1253
- return handleNewSession();
2256
+ return handleNewSession(params);
1254
2257
  case "session/prompt":
1255
2258
  return handlePrompt(params);
1256
2259
  case "session/list":
@@ -1267,6 +2270,26 @@ export async function runAcpMode(options) {
1267
2270
  return handleSetConfigOption(params);
1268
2271
  case "session/set_mode":
1269
2272
  return handleSetMode(params);
2273
+ case "_gg/queue":
2274
+ return handleQueue(params);
2275
+ case "_gg/queue/cancel":
2276
+ return handleQueueCancel(params);
2277
+ case "_gg/goals/list":
2278
+ return { goals: await listAcpGoals(agentDir) };
2279
+ case "_gg/goals/create":
2280
+ return handleGoalsCreate(params);
2281
+ case "_gg/goals/delete":
2282
+ return handleGoalsDelete(params);
2283
+ case "_gg/goal/pause":
2284
+ return handleGoalPause(params);
2285
+ case "_gg/goal/update":
2286
+ return handleGoalUpdate(params);
2287
+ case "_gg/goal/critter_model":
2288
+ return handleGoalCritterModel(params);
2289
+ case "_gg/autopilot/set":
2290
+ return handleAutopilotSet(params);
2291
+ case "_gg/checklist":
2292
+ return handleChecklist(params);
1270
2293
  default:
1271
2294
  return undefined;
1272
2295
  }
@@ -1321,6 +2344,11 @@ export async function runAcpMode(options) {
1321
2344
  }
1322
2345
  const { id, method } = frame;
1323
2346
  if (typeof method !== "string") {
2347
+ // A response to one of our own requests (`_gg/ask_user`).
2348
+ if (typeof id === "string" && asks.openIds().includes(id)) {
2349
+ settleAsk(id, frame);
2350
+ continue;
2351
+ }
1324
2352
  if (id !== undefined)
1325
2353
  fail(id, RPC_INVALID_REQUEST, "Missing 'method'");
1326
2354
  continue;
@@ -1337,6 +2365,8 @@ export async function runAcpMode(options) {
1337
2365
  }
1338
2366
  finally {
1339
2367
  rl.close();
2368
+ // Nobody is left to answer a question: it counts as cancel.
2369
+ cancelPendingAsks();
1340
2370
  // The client is gone, so a turn still running has nowhere to report to.
1341
2371
  // Cancelling first is what stops the wait below from being unbounded.
1342
2372
  handleCancel();