@prestyj/cli 5.30.3 → 5.32.0

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