@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.
- package/dist/app-sidecar/goal-routes.d.ts +2 -22
- package/dist/app-sidecar/goal-routes.js +6 -91
- package/dist/app-sidecar/goal-routes.js.map +1 -1
- package/dist/app-sidecar.js +3 -3
- package/dist/app-sidecar.js.map +1 -1
- package/dist/core/goal-summary.d.ts +32 -0
- package/dist/core/goal-summary.js +104 -0
- package/dist/core/goal-summary.js.map +1 -0
- package/dist/core/plan-handoff.d.ts +8 -0
- package/dist/core/plan-handoff.js +11 -0
- package/dist/core/plan-handoff.js.map +1 -1
- package/dist/modes/acp-autopilot.d.ts +27 -0
- package/dist/modes/acp-autopilot.js +54 -0
- package/dist/modes/acp-autopilot.js.map +1 -0
- package/dist/modes/acp-goals.d.ts +48 -0
- package/dist/modes/acp-goals.js +103 -0
- package/dist/modes/acp-goals.js.map +1 -0
- package/dist/modes/acp-mode.d.ts +128 -3
- package/dist/modes/acp-mode.js +1082 -52
- package/dist/modes/acp-mode.js.map +1 -1
- package/dist/tools/grep-scan.d.ts +96 -0
- package/dist/tools/grep-scan.js +283 -0
- package/dist/tools/grep-scan.js.map +1 -0
- package/dist/tools/grep.d.ts +10 -0
- package/dist/tools/grep.js +418 -10
- package/dist/tools/grep.js.map +1 -1
- package/package.json +4 -4
package/dist/modes/acp-mode.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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) =>
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
1029
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
1203
|
-
|
|
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
|
|
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();
|