@prestyj/cli 5.3.0 → 5.4.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.js +696 -59
- package/dist/app-sidecar.js.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/core/agent-session-compaction.test.js +138 -0
- package/dist/core/agent-session-compaction.test.js.map +1 -1
- package/dist/core/agent-session.d.ts +71 -3
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +280 -47
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/autopilot-cycle.d.ts +69 -17
- package/dist/core/autopilot-cycle.d.ts.map +1 -1
- package/dist/core/autopilot-cycle.js +82 -13
- package/dist/core/autopilot-cycle.js.map +1 -1
- package/dist/core/autopilot-cycle.test.js +206 -10
- package/dist/core/autopilot-cycle.test.js.map +1 -1
- package/dist/core/autopilot-gate.d.ts +48 -22
- package/dist/core/autopilot-gate.d.ts.map +1 -1
- package/dist/core/autopilot-gate.js +110 -1
- package/dist/core/autopilot-gate.js.map +1 -1
- package/dist/core/autopilot-gate.test.js +139 -3
- package/dist/core/autopilot-gate.test.js.map +1 -1
- package/dist/core/autopilot-verdict.d.ts +6 -0
- package/dist/core/autopilot-verdict.d.ts.map +1 -1
- package/dist/core/autopilot-verdict.js +75 -1
- package/dist/core/autopilot-verdict.js.map +1 -1
- package/dist/core/autopilot-verdict.test.js +63 -0
- package/dist/core/autopilot-verdict.test.js.map +1 -1
- package/dist/core/bash-spawn-benchmark.d.ts +2 -0
- package/dist/core/bash-spawn-benchmark.d.ts.map +1 -0
- package/dist/core/bash-spawn-benchmark.js +139 -0
- package/dist/core/bash-spawn-benchmark.js.map +1 -0
- package/dist/core/compaction/compactor.test.js +1 -1
- package/dist/core/compaction/compactor.test.js.map +1 -1
- package/dist/core/fast-apply-benchmark.d.ts +1 -1
- package/dist/core/fast-apply-benchmark.js +2 -2
- package/dist/core/fast-apply-benchmark.js.map +1 -1
- package/dist/core/hashline-edit-benchmark.d.ts +33 -1
- package/dist/core/hashline-edit-benchmark.d.ts.map +1 -1
- package/dist/core/hashline-edit-benchmark.js +193 -21
- package/dist/core/hashline-edit-benchmark.js.map +1 -1
- package/dist/core/hashline-edit-benchmark.test.js +51 -2
- package/dist/core/hashline-edit-benchmark.test.js.map +1 -1
- package/dist/core/mcp/deferred-catalog.d.ts +28 -0
- package/dist/core/mcp/deferred-catalog.d.ts.map +1 -0
- package/dist/core/mcp/deferred-catalog.js +72 -0
- package/dist/core/mcp/deferred-catalog.js.map +1 -0
- package/dist/core/mcp/resolve-stdio.d.ts +19 -3
- package/dist/core/mcp/resolve-stdio.d.ts.map +1 -1
- package/dist/core/mcp/resolve-stdio.js +136 -5
- package/dist/core/mcp/resolve-stdio.js.map +1 -1
- package/dist/core/mcp/resolve-stdio.test.js +82 -1
- package/dist/core/mcp/resolve-stdio.test.js.map +1 -1
- package/dist/core/nolan-context.d.ts +26 -2
- package/dist/core/nolan-context.d.ts.map +1 -1
- package/dist/core/nolan-context.js +46 -4
- package/dist/core/nolan-context.js.map +1 -1
- package/dist/core/nolan-context.test.js +53 -12
- package/dist/core/nolan-context.test.js.map +1 -1
- package/dist/core/nolan-prompt.d.ts +2 -15
- package/dist/core/nolan-prompt.d.ts.map +1 -1
- package/dist/core/nolan-prompt.js +96 -15
- package/dist/core/nolan-prompt.js.map +1 -1
- package/dist/core/nolan-prompt.test.js +102 -7
- package/dist/core/nolan-prompt.test.js.map +1 -1
- package/dist/core/persistent-shell.d.ts +27 -0
- package/dist/core/persistent-shell.d.ts.map +1 -0
- package/dist/core/persistent-shell.js +150 -0
- package/dist/core/persistent-shell.js.map +1 -0
- package/dist/core/persistent-shell.test.d.ts +2 -0
- package/dist/core/persistent-shell.test.d.ts.map +1 -0
- package/dist/core/persistent-shell.test.js +84 -0
- package/dist/core/persistent-shell.test.js.map +1 -0
- package/dist/core/process-manager-dev-server-repro.test.js +34 -6
- package/dist/core/process-manager-dev-server-repro.test.js.map +1 -1
- package/dist/core/progress/engine.d.ts +23 -0
- package/dist/core/progress/engine.d.ts.map +1 -0
- package/dist/core/progress/engine.js +131 -0
- package/dist/core/progress/engine.js.map +1 -0
- package/dist/core/progress/engine.test.d.ts +2 -0
- package/dist/core/progress/engine.test.d.ts.map +1 -0
- package/dist/core/progress/engine.test.js +136 -0
- package/dist/core/progress/engine.test.js.map +1 -0
- package/dist/core/progress/git-xp.d.ts +16 -0
- package/dist/core/progress/git-xp.d.ts.map +1 -0
- package/dist/core/progress/git-xp.js +106 -0
- package/dist/core/progress/git-xp.js.map +1 -0
- package/dist/core/progress/git-xp.test.d.ts +2 -0
- package/dist/core/progress/git-xp.test.d.ts.map +1 -0
- package/dist/core/progress/git-xp.test.js +88 -0
- package/dist/core/progress/git-xp.test.js.map +1 -0
- package/dist/core/progress/ranks.d.ts +21 -0
- package/dist/core/progress/ranks.d.ts.map +1 -0
- package/dist/core/progress/ranks.js +141 -0
- package/dist/core/progress/ranks.js.map +1 -0
- package/dist/core/progress/ranks.test.d.ts +2 -0
- package/dist/core/progress/ranks.test.d.ts.map +1 -0
- package/dist/core/progress/ranks.test.js +59 -0
- package/dist/core/progress/ranks.test.js.map +1 -0
- package/dist/core/progress/rebuild.d.ts +9 -0
- package/dist/core/progress/rebuild.d.ts.map +1 -0
- package/dist/core/progress/rebuild.js +119 -0
- package/dist/core/progress/rebuild.js.map +1 -0
- package/dist/core/progress/rebuild.test.d.ts +2 -0
- package/dist/core/progress/rebuild.test.d.ts.map +1 -0
- package/dist/core/progress/rebuild.test.js +88 -0
- package/dist/core/progress/rebuild.test.js.map +1 -0
- package/dist/core/progress/store.d.ts +35 -0
- package/dist/core/progress/store.d.ts.map +1 -0
- package/dist/core/progress/store.js +200 -0
- package/dist/core/progress/store.js.map +1 -0
- package/dist/core/progress/store.test.d.ts +2 -0
- package/dist/core/progress/store.test.d.ts.map +1 -0
- package/dist/core/progress/store.test.js +108 -0
- package/dist/core/progress/store.test.js.map +1 -0
- package/dist/core/progress/types.d.ts +108 -0
- package/dist/core/progress/types.d.ts.map +1 -0
- package/dist/core/progress/types.js +3 -0
- package/dist/core/progress/types.js.map +1 -0
- package/dist/core/project-discovery.d.ts.map +1 -1
- package/dist/core/project-discovery.js +40 -9
- package/dist/core/project-discovery.js.map +1 -1
- package/dist/core/project-discovery.test.d.ts +2 -0
- package/dist/core/project-discovery.test.d.ts.map +1 -0
- package/dist/core/project-discovery.test.js +104 -0
- package/dist/core/project-discovery.test.js.map +1 -0
- package/dist/core/session-history.d.ts +51 -0
- package/dist/core/session-history.d.ts.map +1 -0
- package/dist/core/session-history.js +156 -0
- package/dist/core/session-history.js.map +1 -0
- package/dist/core/session-history.test.d.ts +2 -0
- package/dist/core/session-history.test.d.ts.map +1 -0
- package/dist/core/session-history.test.js +101 -0
- package/dist/core/session-history.test.js.map +1 -0
- package/dist/core/session-manager.d.ts +34 -0
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +67 -0
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/session-manager.test.js +140 -1
- package/dist/core/session-manager.test.js.map +1 -1
- package/dist/core/settings-manager.d.ts +1 -0
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +4 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/system-prompt.d.ts.map +1 -1
- package/dist/system-prompt.js +12 -8
- package/dist/system-prompt.js.map +1 -1
- package/dist/system-prompt.test.js +15 -20
- package/dist/system-prompt.test.js.map +1 -1
- package/dist/tools/bash.d.ts +1 -0
- package/dist/tools/bash.d.ts.map +1 -1
- package/dist/tools/bash.js +27 -2
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/edit.d.ts +9 -2
- package/dist/tools/edit.d.ts.map +1 -1
- package/dist/tools/edit.js +91 -10
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/edit.test.js +97 -0
- package/dist/tools/edit.test.js.map +1 -1
- package/dist/tools/plan-mode.test.js +25 -0
- package/dist/tools/plan-mode.test.js.map +1 -1
- package/dist/tools/prompt-hints.d.ts.map +1 -1
- package/dist/tools/prompt-hints.js +3 -0
- package/dist/tools/prompt-hints.js.map +1 -1
- package/dist/tools/subagent.d.ts +2 -0
- package/dist/tools/subagent.d.ts.map +1 -1
- package/dist/tools/subagent.js +213 -194
- package/dist/tools/subagent.js.map +1 -1
- package/dist/tools/subagent.test.d.ts +2 -0
- package/dist/tools/subagent.test.d.ts.map +1 -0
- package/dist/tools/subagent.test.js +73 -0
- package/dist/tools/subagent.test.js.map +1 -0
- package/dist/tools/tool-search.d.ts +15 -0
- package/dist/tools/tool-search.d.ts.map +1 -0
- package/dist/tools/tool-search.js +35 -0
- package/dist/tools/tool-search.js.map +1 -0
- package/dist/ui/components/Footer.js +3 -3
- package/dist/ui/components/Footer.js.map +1 -1
- package/dist/ui/hooks/useAgentLoop.d.ts.map +1 -1
- package/dist/ui/hooks/useAgentLoop.js +22 -9
- package/dist/ui/hooks/useAgentLoop.js.map +1 -1
- package/package.json +4 -4
package/dist/app-sidecar.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import http from "node:http";
|
|
15
15
|
import fs from "node:fs/promises";
|
|
16
|
+
import { watch as fsWatch } from "node:fs";
|
|
16
17
|
import os from "node:os";
|
|
17
18
|
import path from "node:path";
|
|
18
19
|
import { randomUUID } from "node:crypto";
|
|
@@ -21,14 +22,14 @@ import { formatError } from "@prestyj/ai";
|
|
|
21
22
|
import { runJsonMode } from "./modes/json-mode.js";
|
|
22
23
|
import { AgentSession } from "./core/agent-session.js";
|
|
23
24
|
import { buildNolanSystemPrompt, buildNolanAutopilotSystemPrompt } from "./core/nolan-prompt.js";
|
|
24
|
-
import { buildNolanDigest, buildNolanAutopilotContext } from "./core/nolan-context.js";
|
|
25
|
+
import { buildNolanDigest, buildNolanAutopilotContext, buildNolanAutopilotPlanContext, } from "./core/nolan-context.js";
|
|
25
26
|
import { parseAutopilotVerdict } from "./core/autopilot-verdict.js";
|
|
26
|
-
import { isWorkflowCommandText, countAssistantMessages, shouldStartAutopilotCycle, } from "./core/autopilot-gate.js";
|
|
27
|
-
import { driveAutopilotCycle } from "./core/autopilot-cycle.js";
|
|
27
|
+
import { isWorkflowCommandText, countAssistantMessages, shouldStartAutopilotCycle, extractTurnToolCalls, isMechanicalOnlyTurn, } from "./core/autopilot-gate.js";
|
|
28
|
+
import { driveAutopilotCycle, frameAutopilotInjection } from "./core/autopilot-cycle.js";
|
|
28
29
|
import { validateNolanModelPref, effectiveNolanModel, } from "./core/nolan-model.js";
|
|
29
|
-
import {
|
|
30
|
+
import { normalizeAutopilotMarkersForHistory, normalizeAppMarkersForHistory, normalizeNolanTurnsForHistory, restoreUserRow, restoreAssistantTexts, autopilotMarkerCopySeed, } from "./core/session-history.js";
|
|
30
31
|
import { AuthStorage } from "./core/auth-storage.js";
|
|
31
|
-
import { MOONSHOT_OAUTH_KEY, XIAOMI_CREDITS_KEY } from "@prestyj/core";
|
|
32
|
+
import { fetchSubscriptionUsage, MOONSHOT_OAUTH_KEY, SubscriptionUsageError, XIAOMI_CREDITS_KEY, } from "@prestyj/core";
|
|
32
33
|
import { loginAnthropic } from "./core/oauth/anthropic.js";
|
|
33
34
|
import { loginOpenAI } from "./core/oauth/openai.js";
|
|
34
35
|
import { loginGemini } from "./core/oauth/gemini.js";
|
|
@@ -51,6 +52,11 @@ import { downscaleForPreview, validateVisionImage } from "./utils/image.js";
|
|
|
51
52
|
import { startServeMode } from "./modes/serve-mode.js";
|
|
52
53
|
import { loadTelegramConfig, saveTelegramConfig, verifyBotToken } from "./core/telegram-config.js";
|
|
53
54
|
import { loadServers, addServer, removeServer, getServer, parseMcpAddCommand, MCPClientManager, McpOAuthStore, } from "./core/mcp/index.js";
|
|
55
|
+
import { buildSnapshot, levelForXp, rankForLevel } from "./core/progress/ranks.js";
|
|
56
|
+
import { loadProgress, peekProgress, updateProgress } from "./core/progress/store.js";
|
|
57
|
+
import { awardPrompt, awardCommits } from "./core/progress/engine.js";
|
|
58
|
+
import { detectNewCommits, repoKey } from "./core/progress/git-xp.js";
|
|
59
|
+
import { rebuildFromSessions } from "./core/progress/rebuild.js";
|
|
54
60
|
const ALL_PROVIDERS = [
|
|
55
61
|
"anthropic",
|
|
56
62
|
"xiaomi",
|
|
@@ -496,6 +502,77 @@ async function main() {
|
|
|
496
502
|
// request to its window's session via the `x-gg-session` header (and the
|
|
497
503
|
// `?session=` query for the SSE /events stream).
|
|
498
504
|
const sessions = new Map();
|
|
505
|
+
// XP/rank progress — loaded once per daemon; awards fan out to every window.
|
|
506
|
+
// Each frame is tagged `origin: true` only for the session that earned the
|
|
507
|
+
// XP, so that window alone plays sounds/chips while the rest just re-render.
|
|
508
|
+
const progress = await createProgressManager(paths.agentDir, (snapshot, originId) => {
|
|
509
|
+
for (const ctx of sessions.values())
|
|
510
|
+
ctx.broadcast("progress", { ...snapshot, origin: ctx.id === originId });
|
|
511
|
+
});
|
|
512
|
+
const usageCache = new Map();
|
|
513
|
+
const usageRequests = new Map();
|
|
514
|
+
async function fetchUsageProvider(provider) {
|
|
515
|
+
const displayName = provider === "anthropic" ? "Anthropic" : "Codex";
|
|
516
|
+
if (!(await auth.hasProviderAuth(provider))) {
|
|
517
|
+
return { provider, displayName, connected: false, windows: [], fetchedAt: Date.now() };
|
|
518
|
+
}
|
|
519
|
+
try {
|
|
520
|
+
let credentials = await auth.resolveCredentials(provider);
|
|
521
|
+
try {
|
|
522
|
+
return { ...(await fetchSubscriptionUsage(provider, credentials)), connected: true };
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
// A provider can revoke an access token before its stored expiry. Refresh
|
|
526
|
+
// once on 401, matching inference auth recovery, then retry the usage call.
|
|
527
|
+
if (error instanceof SubscriptionUsageError && error.status === 401) {
|
|
528
|
+
credentials = await auth.resolveCredentials(provider, { forceRefresh: true });
|
|
529
|
+
return { ...(await fetchSubscriptionUsage(provider, credentials)), connected: true };
|
|
530
|
+
}
|
|
531
|
+
throw error;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
536
|
+
log("WARN", "app-sidecar", "subscription usage fetch failed", { provider, message });
|
|
537
|
+
const connected = await auth.hasProviderAuth(provider);
|
|
538
|
+
return {
|
|
539
|
+
provider,
|
|
540
|
+
displayName,
|
|
541
|
+
connected,
|
|
542
|
+
windows: [],
|
|
543
|
+
fetchedAt: Date.now(),
|
|
544
|
+
error: connected ? "Usage is temporarily unavailable." : undefined,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
async function subscriptionUsage(provider) {
|
|
549
|
+
const cached = usageCache.get(provider);
|
|
550
|
+
if (cached && cached.expiresAt > Date.now())
|
|
551
|
+
return cached.result;
|
|
552
|
+
const inFlight = usageRequests.get(provider);
|
|
553
|
+
if (inFlight)
|
|
554
|
+
return inFlight;
|
|
555
|
+
const request = fetchUsageProvider(provider);
|
|
556
|
+
usageRequests.set(provider, request);
|
|
557
|
+
try {
|
|
558
|
+
const result = await request;
|
|
559
|
+
// Anthropic can return utilization before it assigns reset timestamps
|
|
560
|
+
// (notably before the account's first active request). Retry that partial
|
|
561
|
+
// snapshot quickly; complete snapshots keep the normal one-minute cache.
|
|
562
|
+
const missingReset = result.connected &&
|
|
563
|
+
result.windows.length > 0 &&
|
|
564
|
+
result.windows.some((window) => window.resetsAt === undefined);
|
|
565
|
+
usageCache.set(provider, {
|
|
566
|
+
result,
|
|
567
|
+
expiresAt: Date.now() + (missingReset ? 10_000 : 60_000),
|
|
568
|
+
});
|
|
569
|
+
return result;
|
|
570
|
+
}
|
|
571
|
+
finally {
|
|
572
|
+
if (usageRequests.get(provider) === request)
|
|
573
|
+
usageRequests.delete(provider);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
499
576
|
/** Resolve the target session id: the `x-gg-session` header, else a
|
|
500
577
|
* `?session=` query param (used by the SSE /events connection). */
|
|
501
578
|
function sessionIdFromReq(req, url) {
|
|
@@ -539,7 +616,7 @@ async function main() {
|
|
|
539
616
|
const sessionPath = typeof body.sessionPath === "string" && body.sessionPath ? body.sessionPath : undefined;
|
|
540
617
|
const id = randomUUID();
|
|
541
618
|
try {
|
|
542
|
-
const ctx = await createSession({ auth, paths }, { id, cwd: sessionCwd, sessionPath });
|
|
619
|
+
const ctx = await createSession({ auth, paths, progress }, { id, cwd: sessionCwd, sessionPath });
|
|
543
620
|
sessions.set(id, ctx);
|
|
544
621
|
log("INFO", "app-sidecar", "session created", { id, cwd: sessionCwd });
|
|
545
622
|
daemonJson(res, 200, { sessionId: id });
|
|
@@ -564,6 +641,31 @@ async function main() {
|
|
|
564
641
|
daemonJson(res, 200, { ok: true });
|
|
565
642
|
return;
|
|
566
643
|
}
|
|
644
|
+
// Progress is daemon-level so the Home screen can paint before a project
|
|
645
|
+
// session exists; per-session callers still work through the same endpoint.
|
|
646
|
+
if (method === "GET" && url === "/progress") {
|
|
647
|
+
daemonJson(res, 200, progress.snapshot());
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
// Subscription quota is account-wide, not project/session-specific. OAuth
|
|
651
|
+
// tokens stay in this daemon; only the active provider's normalized snapshot
|
|
652
|
+
// reaches the webview.
|
|
653
|
+
if (method === "GET" && (url === "/usage" || url.startsWith("/usage?"))) {
|
|
654
|
+
void (async () => {
|
|
655
|
+
const provider = new URL(url, `http://${host}`).searchParams.get("provider");
|
|
656
|
+
if (provider !== "anthropic" && provider !== "openai") {
|
|
657
|
+
daemonJson(res, 400, { error: "unsupported usage provider" });
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
daemonJson(res, 200, await subscriptionUsage(provider));
|
|
661
|
+
})().catch((error) => {
|
|
662
|
+
log("ERROR", "app-sidecar", "subscription usage request failed", {
|
|
663
|
+
message: error instanceof Error ? error.message : String(error),
|
|
664
|
+
});
|
|
665
|
+
daemonJson(res, 500, { error: "Usage is temporarily unavailable." });
|
|
666
|
+
});
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
567
669
|
// ── Per-session delegation ───────────────────────────────────────────
|
|
568
670
|
const id = sessionIdFromReq(req, url);
|
|
569
671
|
const ctx = id ? sessions.get(id) : undefined;
|
|
@@ -623,17 +725,18 @@ function lastAssistantText(messages) {
|
|
|
623
725
|
return "";
|
|
624
726
|
}
|
|
625
727
|
/**
|
|
626
|
-
* Assemble Nolan's context digest for one `@Nolan` question:
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
728
|
+
* Assemble Nolan's context digest for one `@Nolan` question: git/env + the build
|
|
729
|
+
* session's compaction summary + recent activity. Prepended to the user's
|
|
730
|
+
* question as Nolan's prompt body each turn. Project docs (CLAUDE.md/AGENTS.md)
|
|
731
|
+
* are NOT here — they're folded into Nolan's cached system prompt once per
|
|
732
|
+
* session instead (see nolan-prompt.ts), so they hit the provider prompt cache
|
|
733
|
+
* instead of being re-sent uncached on every question. Workflow commands +
|
|
734
|
+
* autopilot-injected prompts are passed through so the digest labels them as
|
|
735
|
+
* what they are instead of user-authored asks.
|
|
631
736
|
*/
|
|
632
|
-
|
|
633
|
-
const projectContext = await collectProjectContext(cwd).catch(() => []);
|
|
737
|
+
function buildNolanContext(buildSession, cwd, gitBranch, question, workflowCommands, injectedPrompts) {
|
|
634
738
|
return buildNolanDigest({
|
|
635
739
|
question,
|
|
636
|
-
projectContext,
|
|
637
740
|
cwd,
|
|
638
741
|
gitBranch,
|
|
639
742
|
messages: buildSession.getMessages(),
|
|
@@ -641,6 +744,88 @@ async function buildNolanContext(buildSession, cwd, gitBranch, question, workflo
|
|
|
641
744
|
injectedPrompts,
|
|
642
745
|
});
|
|
643
746
|
}
|
|
747
|
+
async function createProgressManager(agentDir, broadcastAll) {
|
|
748
|
+
// Boot: recovery chain main → backup → one-time session-history rebuild → empty.
|
|
749
|
+
let file = await loadProgress({ rebuild: () => rebuildFromSessions() });
|
|
750
|
+
// Don't re-celebrate an old levelUp event on boot.
|
|
751
|
+
let lastSeenNonce = file.lastEvent?.nonce ?? null;
|
|
752
|
+
log("INFO", "app-sidecar", "progress loaded", {
|
|
753
|
+
xp: String(file.xp),
|
|
754
|
+
level: String(levelForXp(file.xp)),
|
|
755
|
+
});
|
|
756
|
+
function snapshot() {
|
|
757
|
+
return buildSnapshot(file);
|
|
758
|
+
}
|
|
759
|
+
async function awardRun(cwd, runStartedAt, originId) {
|
|
760
|
+
try {
|
|
761
|
+
const now = Date.now();
|
|
762
|
+
const updated = await updateProgress(async (f) => {
|
|
763
|
+
const levelBefore = levelForXp(f.xp);
|
|
764
|
+
awardPrompt(f, now, cwd);
|
|
765
|
+
// Commit XP: probe repo root + HEAD, then score lastHead..HEAD bounded
|
|
766
|
+
// by the run window. First sight of a repo records HEAD, scores nothing.
|
|
767
|
+
const probe = await detectNewCommits(cwd, undefined, runStartedAt);
|
|
768
|
+
if (probe) {
|
|
769
|
+
const key = repoKey(probe.repoRoot);
|
|
770
|
+
const lastHead = f.repos[key]?.lastHead;
|
|
771
|
+
if (lastHead && lastHead !== probe.head) {
|
|
772
|
+
const detected = await detectNewCommits(cwd, lastHead, runStartedAt);
|
|
773
|
+
if (detected && detected.commits.length > 0) {
|
|
774
|
+
awardCommits(f, detected.commits, now, cwd);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
f.repos[key] = { lastHead: probe.head };
|
|
778
|
+
}
|
|
779
|
+
// One combined lastEvent per run so other windows celebrate exactly once.
|
|
780
|
+
const levelAfter = levelForXp(f.xp);
|
|
781
|
+
const levelUp = levelAfter > levelBefore
|
|
782
|
+
? { from: levelBefore, to: levelAfter, rankName: rankForLevel(levelAfter).name }
|
|
783
|
+
: null;
|
|
784
|
+
f.lastEvent = { nonce: randomUUID(), levelUp };
|
|
785
|
+
return { file: f, levelledUp: levelUp !== null };
|
|
786
|
+
});
|
|
787
|
+
file = updated;
|
|
788
|
+
lastSeenNonce = updated.lastEvent?.nonce ?? null;
|
|
789
|
+
broadcastAll(buildSnapshot(updated), originId);
|
|
790
|
+
}
|
|
791
|
+
catch (err) {
|
|
792
|
+
log("DEBUG", "app-sidecar", "progress award failed", {
|
|
793
|
+
message: err instanceof Error ? err.message : String(err),
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
// Watch ~/.gg (dir watch survives the atomic tmp+rename) for progress.json
|
|
798
|
+
// writes from other daemon processes; debounce, reload read-only, dedupe by nonce.
|
|
799
|
+
let watchDebounce = null;
|
|
800
|
+
try {
|
|
801
|
+
const watcher = fsWatch(agentDir, (_event, filename) => {
|
|
802
|
+
if (filename !== "progress.json")
|
|
803
|
+
return;
|
|
804
|
+
if (watchDebounce)
|
|
805
|
+
clearTimeout(watchDebounce);
|
|
806
|
+
watchDebounce = setTimeout(() => {
|
|
807
|
+
void (async () => {
|
|
808
|
+
const reloaded = await peekProgress();
|
|
809
|
+
if (!reloaded)
|
|
810
|
+
return;
|
|
811
|
+
const nonce = reloaded.lastEvent?.nonce ?? null;
|
|
812
|
+
if (nonce === lastSeenNonce)
|
|
813
|
+
return;
|
|
814
|
+
file = reloaded;
|
|
815
|
+
lastSeenNonce = nonce;
|
|
816
|
+
broadcastAll(buildSnapshot(reloaded));
|
|
817
|
+
})();
|
|
818
|
+
}, 150);
|
|
819
|
+
});
|
|
820
|
+
watcher.unref();
|
|
821
|
+
}
|
|
822
|
+
catch (err) {
|
|
823
|
+
log("DEBUG", "app-sidecar", "progress watch unavailable", {
|
|
824
|
+
message: err instanceof Error ? err.message : String(err),
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
return { snapshot, awardRun };
|
|
828
|
+
}
|
|
644
829
|
/**
|
|
645
830
|
* Build one in-process agent session: its AgentSession, SSE client set, event
|
|
646
831
|
* bridge, task runner, auth/login bridge, and the full HTTP route table exposed
|
|
@@ -649,7 +834,7 @@ async function buildNolanContext(buildSession, cwd, gitBranch, question, workflo
|
|
|
649
834
|
* logger, PATH, shared auth file, and radio live at the daemon level.
|
|
650
835
|
*/
|
|
651
836
|
async function createSession(deps, opts) {
|
|
652
|
-
const { auth } = deps;
|
|
837
|
+
const { auth, progress } = deps;
|
|
653
838
|
const paths = deps.paths;
|
|
654
839
|
const cwd = opts.cwd;
|
|
655
840
|
// Base host for parsing request-URL query params (value is irrelevant to
|
|
@@ -686,6 +871,32 @@ async function createSession(deps, opts) {
|
|
|
686
871
|
for (const c of clients)
|
|
687
872
|
c.res.write(frame);
|
|
688
873
|
}
|
|
874
|
+
// Replace CLI-specific guidance (slash commands, CLI tool names) with
|
|
875
|
+
// desktop-app equivalents so the webview never shows "run ezcoder login".
|
|
876
|
+
// Applied to BOTH the message and guidance fields — the auth "Not logged in…
|
|
877
|
+
// Run "ezcoder login"" string lives in `message`, not `guidance`.
|
|
878
|
+
function desktopGuidance(guidance) {
|
|
879
|
+
return (guidance
|
|
880
|
+
// Auth: `Run "ezcoder login"` / `Run 'ezcoder login'` / `Run `ezcoder login``
|
|
881
|
+
// (any quote style, or none) → button. Do this first so the whole phrase
|
|
882
|
+
// is rewritten cleanly instead of leaving a dangling `Run "…"`.
|
|
883
|
+
.replaceAll(/Run ["'`]?ezcoder login["'`]?/gi, "Use the Login to AI Providers button")
|
|
884
|
+
// Any remaining bare mention.
|
|
885
|
+
.replaceAll(/ezcoder login/gi, "the Login to AI Providers button")
|
|
886
|
+
// /compact: the app has NO manual compact command or button — it only
|
|
887
|
+
// auto-compacts (and now auto-recovers on overflow). If this guidance is
|
|
888
|
+
// reached, auto-compaction already couldn't reduce enough, so the only
|
|
889
|
+
// real affordance is a fresh session. Don't tell the user to run a
|
|
890
|
+
// command that doesn't exist in the app.
|
|
891
|
+
.replaceAll(/Run \/compact to shrink history, or start a new session\./gi, "Start a new session to reset the context.")
|
|
892
|
+
// /model: handle each phrasing pattern
|
|
893
|
+
.replaceAll(/switch to claude-fable-5 with \/model/gi, "switch to claude-fable-5 using the model selector")
|
|
894
|
+
.replaceAll(/Switch with \/model\./gi, "Switch using the model selector.")
|
|
895
|
+
.replaceAll(/try a different model with \/model\./gi, "try a different model using the model selector.")
|
|
896
|
+
.replaceAll(/Use \/model to switch/gi, "Use the model selector to switch")
|
|
897
|
+
// /help
|
|
898
|
+
.replaceAll(/see \/help/gi, "check the help menu"));
|
|
899
|
+
}
|
|
689
900
|
// Turn any thrown value into the same clear headline/message/guidance shape
|
|
690
901
|
// the TUI shows (see gg-ai's formatError) instead of a bare `err.message`, log
|
|
691
902
|
// the full detail, and broadcast it under `type` ("error" or "nolan_error").
|
|
@@ -694,22 +905,34 @@ async function createSession(deps, opts) {
|
|
|
694
905
|
// context that the CLI has always given.
|
|
695
906
|
function broadcastError(type, logLabel, err) {
|
|
696
907
|
const f = formatError(err);
|
|
908
|
+
const message = f.message ? desktopGuidance(f.message) : undefined;
|
|
909
|
+
const guidance = desktopGuidance(f.guidance);
|
|
697
910
|
log("ERROR", "app-sidecar", logLabel, {
|
|
698
911
|
headline: f.headline,
|
|
699
912
|
source: f.source,
|
|
700
|
-
...(
|
|
913
|
+
...(message ? { message } : {}),
|
|
701
914
|
...(f.provider ? { provider: f.provider } : {}),
|
|
702
915
|
...(f.statusCode != null ? { statusCode: String(f.statusCode) } : {}),
|
|
703
916
|
...(f.requestId ? { requestId: f.requestId } : {}),
|
|
704
917
|
});
|
|
705
918
|
broadcast(type, {
|
|
706
919
|
headline: f.headline,
|
|
707
|
-
...(
|
|
708
|
-
guidance
|
|
920
|
+
...(message ? { message } : {}),
|
|
921
|
+
guidance,
|
|
709
922
|
...(f.provider ? { provider: f.provider } : {}),
|
|
710
923
|
...(f.statusCode != null ? { statusCode: f.statusCode } : {}),
|
|
711
924
|
...(f.resetsAt != null ? { resetsAt: f.resetsAt } : {}),
|
|
712
925
|
});
|
|
926
|
+
// Persist the error row (display-only marker) so a resumed session shows
|
|
927
|
+
// the same headline/message/guidance the live run did. Best-effort.
|
|
928
|
+
void session
|
|
929
|
+
.persistAppMarker("error", {
|
|
930
|
+
scope: type,
|
|
931
|
+
headline: f.headline,
|
|
932
|
+
...(message ? { message } : {}),
|
|
933
|
+
guidance,
|
|
934
|
+
})
|
|
935
|
+
.catch(() => { });
|
|
713
936
|
}
|
|
714
937
|
// The session file path to resume (passed by the daemon's POST /session);
|
|
715
938
|
// empty/unset starts a fresh session.
|
|
@@ -732,10 +955,27 @@ async function createSession(deps, opts) {
|
|
|
732
955
|
// session plan state (rebuilds the system prompt + enforces read-only
|
|
733
956
|
// tools) and surface the transition to the webview.
|
|
734
957
|
onEnterPlan: async (reason) => {
|
|
958
|
+
// During a task run there is no human present to approve a plan, and a
|
|
959
|
+
// submitted plan would strand the task in-progress while the run-all loop
|
|
960
|
+
// advances to the next one. Decline plan mode so the agent implements the
|
|
961
|
+
// task directly — mirrors the TUI's isUnattendedRun guard (App.tsx). The
|
|
962
|
+
// enter_plan tool turns a false return into an "implement directly" hint.
|
|
963
|
+
if (taskRunActive)
|
|
964
|
+
return false;
|
|
735
965
|
await session.setPlanMode(true);
|
|
736
966
|
broadcast("plan_enter", { reason: reason ?? "" });
|
|
967
|
+
// Persist the plan-mode banner so a resumed session still shows it.
|
|
968
|
+
void session.persistAppMarker("plan", { reason: reason ?? "" }).catch(() => { });
|
|
737
969
|
},
|
|
738
970
|
onExitPlan: async (planPath) => {
|
|
971
|
+
// A model can call exit_plan even when enter_plan was declined. During a
|
|
972
|
+
// task run, never route the plan into review (no approver) — that would
|
|
973
|
+
// hang the task and stall run-all. Leave plan mode and tell it to build.
|
|
974
|
+
if (taskRunActive) {
|
|
975
|
+
await session.setPlanMode(false);
|
|
976
|
+
return ("Plan review is unavailable during a task run. Skip review and implement " +
|
|
977
|
+
"the plan directly now: make the changes, verify them, then mark the task done.");
|
|
978
|
+
}
|
|
739
979
|
await session.setPlanMode(false);
|
|
740
980
|
// Surface the plan's path + markdown so the webview can show the review
|
|
741
981
|
// modal (Accept / Feedback / Reject). Best-effort content read.
|
|
@@ -746,6 +986,12 @@ async function createSession(deps, opts) {
|
|
|
746
986
|
catch {
|
|
747
987
|
content = "";
|
|
748
988
|
}
|
|
989
|
+
// Record the submitted plan so the autopilot gate can route this turn
|
|
990
|
+
// into a PLAN review instead of a stale work review (plan mode is
|
|
991
|
+
// already false here, so the gate's planMode check alone never catches
|
|
992
|
+
// a submission). setPendingPlan bumps planGeneration, which invalidates
|
|
993
|
+
// any in-flight Nolan plan review racing a user action.
|
|
994
|
+
setPendingPlan(planPath, content);
|
|
749
995
|
broadcast("plan_exit", { planPath, content });
|
|
750
996
|
return "Plan submitted for user review. Wait for the user to approve, reject, or dismiss it before implementing.";
|
|
751
997
|
},
|
|
@@ -818,6 +1064,9 @@ async function createSession(deps, opts) {
|
|
|
818
1064
|
session.eventBus.on("compaction_end", (d) => broadcast("compaction_end", d));
|
|
819
1065
|
let running = false;
|
|
820
1066
|
let titleGenerated = false;
|
|
1067
|
+
// Bumped by /cancel — a run whose cancel generation changed mid-flight was
|
|
1068
|
+
// canceled and earns no XP.
|
|
1069
|
+
let cancelGeneration = 0;
|
|
821
1070
|
// Autopilot (auto-review) toggle for THIS window's project. Loaded from
|
|
822
1071
|
// ezcoder-app.json on boot; flipped via POST /autopilot. When on, POST /prompt runs
|
|
823
1072
|
// runAutopilotCycle after the user's turn settles — Nolan auto-reviews the work
|
|
@@ -842,6 +1091,28 @@ async function createSession(deps, opts) {
|
|
|
842
1091
|
// cycles drift into Nolan reviewing against his own last prompt. Cleared
|
|
843
1092
|
// whenever the conversation resets (new session / plan accept / task run).
|
|
844
1093
|
let injectedAutopilotPrompts = [];
|
|
1094
|
+
// The plan EZ Coder submitted via exit_plan that still awaits a decision
|
|
1095
|
+
// (Nolan's auto-review in autopilot, or the user's modal). Path + the content
|
|
1096
|
+
// read at submission time (fallback if the file becomes unreadable).
|
|
1097
|
+
let pendingPlanPath = null;
|
|
1098
|
+
let pendingPlanContent = "";
|
|
1099
|
+
// Bumped on EVERY pending-plan set/clear. Nolan's plan review captures it
|
|
1100
|
+
// before reviewing and re-checks it before acting on the verdict, so a user
|
|
1101
|
+
// Accept/Reject racing an in-flight review always wins — the stale verdict
|
|
1102
|
+
// is discarded silently.
|
|
1103
|
+
let planGeneration = 0;
|
|
1104
|
+
function setPendingPlan(planPath, content) {
|
|
1105
|
+
pendingPlanPath = planPath;
|
|
1106
|
+
pendingPlanContent = content;
|
|
1107
|
+
planGeneration++;
|
|
1108
|
+
}
|
|
1109
|
+
function clearPendingPlan() {
|
|
1110
|
+
if (pendingPlanPath === null)
|
|
1111
|
+
return;
|
|
1112
|
+
pendingPlanPath = null;
|
|
1113
|
+
pendingPlanContent = "";
|
|
1114
|
+
planGeneration++;
|
|
1115
|
+
}
|
|
845
1116
|
// Workflow (prompt-template) commands: built-in + the project's custom
|
|
846
1117
|
// `.ezcoder/commands/*.md`. Used to gate autopilot off command turns and to label
|
|
847
1118
|
// expanded templates in Nolan's digests. Loaded fresh so a newly added custom
|
|
@@ -918,11 +1189,14 @@ async function createSession(deps, opts) {
|
|
|
918
1189
|
provider: target.provider,
|
|
919
1190
|
model: target.model,
|
|
920
1191
|
cwd,
|
|
921
|
-
systemPrompt: buildNolanSystemPrompt(),
|
|
1192
|
+
systemPrompt: await buildNolanSystemPrompt(cwd),
|
|
922
1193
|
allowedTools: NOLAN_ALLOWED_TOOLS,
|
|
923
1194
|
allowedMcpServers: NOLAN_ALLOWED_MCP_SERVERS,
|
|
924
1195
|
transient: true,
|
|
925
1196
|
signal: nolanAbort.signal,
|
|
1197
|
+
// Nolan's bursty, spread-out turns (chat) outlast the default 5-min cache
|
|
1198
|
+
// TTL regardless of the user's global speedProfile pick.
|
|
1199
|
+
forceLongCacheRetention: true,
|
|
926
1200
|
});
|
|
927
1201
|
await ken.initialize();
|
|
928
1202
|
// Bridge Nolan's bus to the shared SSE fan-out with ken_-prefixed types so the
|
|
@@ -984,11 +1258,14 @@ async function createSession(deps, opts) {
|
|
|
984
1258
|
provider: target.provider,
|
|
985
1259
|
model: target.model,
|
|
986
1260
|
cwd,
|
|
987
|
-
systemPrompt: buildNolanAutopilotSystemPrompt(),
|
|
1261
|
+
systemPrompt: await buildNolanAutopilotSystemPrompt(cwd),
|
|
988
1262
|
allowedTools: NOLAN_ALLOWED_TOOLS,
|
|
989
1263
|
allowedMcpServers: NOLAN_ALLOWED_MCP_SERVERS,
|
|
990
1264
|
transient: true,
|
|
991
1265
|
signal: nolanAutoAbort.signal,
|
|
1266
|
+
// Autopilot review rounds routinely span the injected EZ Coder run
|
|
1267
|
+
// (often >5 min) regardless of the user's global speedProfile pick.
|
|
1268
|
+
forceLongCacheRetention: true,
|
|
992
1269
|
});
|
|
993
1270
|
await ken.initialize();
|
|
994
1271
|
// Deliberately no bus bridge: the review is silent. Errors surface via the
|
|
@@ -1020,15 +1297,28 @@ async function createSession(deps, opts) {
|
|
|
1020
1297
|
// run_start frame.
|
|
1021
1298
|
async function runAgent(label, run) {
|
|
1022
1299
|
running = true;
|
|
1300
|
+
// Progress (Ranks): completed, non-canceled runs with ≥1 assistant turn earn
|
|
1301
|
+
// XP — prompt + any commits authored during the run window.
|
|
1302
|
+
const runStartedAt = Date.now();
|
|
1303
|
+
const cancelGenAtStart = cancelGeneration;
|
|
1304
|
+
const assistantsBeforeRun = countAssistantMessages(session.getMessages());
|
|
1305
|
+
let runSucceeded = false;
|
|
1023
1306
|
broadcast("run_start", { text: label });
|
|
1024
1307
|
try {
|
|
1025
1308
|
await run();
|
|
1309
|
+
runSucceeded = true;
|
|
1026
1310
|
}
|
|
1027
1311
|
catch (err) {
|
|
1028
1312
|
broadcastError("error", "run failed", err);
|
|
1029
1313
|
}
|
|
1030
1314
|
finally {
|
|
1031
1315
|
running = false;
|
|
1316
|
+
if (runSucceeded &&
|
|
1317
|
+
cancelGeneration === cancelGenAtStart &&
|
|
1318
|
+
countAssistantMessages(session.getMessages()) > assistantsBeforeRun) {
|
|
1319
|
+
// Fire-and-forget — XP must never delay or break run teardown.
|
|
1320
|
+
void progress.awardRun(cwd, runStartedAt, opts.id);
|
|
1321
|
+
}
|
|
1032
1322
|
// A run may have switched branches (git checkout) or spawned/finished
|
|
1033
1323
|
// background tasks — refresh the footer extras once it settles.
|
|
1034
1324
|
gitBranch = await getGitBranch(cwd).catch(() => gitBranch);
|
|
@@ -1068,9 +1358,7 @@ async function createSession(deps, opts) {
|
|
|
1068
1358
|
broadcast("autopilot_review_start", {});
|
|
1069
1359
|
try {
|
|
1070
1360
|
const ken = await ensureNolanAutoSession();
|
|
1071
|
-
const projectContext = await collectProjectContext(cwd).catch(() => []);
|
|
1072
1361
|
const digest = buildNolanAutopilotContext({
|
|
1073
|
-
projectContext,
|
|
1074
1362
|
cwd,
|
|
1075
1363
|
gitBranch,
|
|
1076
1364
|
messages: session.getMessages(),
|
|
@@ -1094,6 +1382,60 @@ async function createSession(deps, opts) {
|
|
|
1094
1382
|
await syncNolanAutoModel(pending.provider, pending.model);
|
|
1095
1383
|
}
|
|
1096
1384
|
}
|
|
1385
|
+
// One PLAN review: like runAutopilotReview but the digest carries the
|
|
1386
|
+
// submitted plan's markdown (`## Plan under review`) and the plan-review
|
|
1387
|
+
// instruction — Nolan judges the plan itself, not finished work. Returns null
|
|
1388
|
+
// on failure; a failure caused by the user's own action racing the review
|
|
1389
|
+
// (cancel or a manual Accept/Reject that bumped planGeneration) stays
|
|
1390
|
+
// SILENT — no autopilot_error — because the user's decision already won.
|
|
1391
|
+
async function runAutopilotPlanReview(originalRequest) {
|
|
1392
|
+
const planPath = pendingPlanPath;
|
|
1393
|
+
if (planPath === null)
|
|
1394
|
+
return null;
|
|
1395
|
+
const genAtStart = planGeneration;
|
|
1396
|
+
autopilotReviewing = true;
|
|
1397
|
+
broadcast("autopilot_review_start", {});
|
|
1398
|
+
try {
|
|
1399
|
+
const ken = await ensureNolanAutoSession();
|
|
1400
|
+
// Re-read the plan file (the run may have revised it in place); fall
|
|
1401
|
+
// back to the content captured at exit_plan time.
|
|
1402
|
+
const planContent = await fs.readFile(planPath, "utf-8").catch(() => pendingPlanContent);
|
|
1403
|
+
const digest = buildNolanAutopilotPlanContext({
|
|
1404
|
+
cwd,
|
|
1405
|
+
gitBranch,
|
|
1406
|
+
messages: session.getMessages(),
|
|
1407
|
+
originalRequest,
|
|
1408
|
+
injectedPrompts: [...injectedAutopilotPrompts],
|
|
1409
|
+
workflowCommands: await loadWorkflowCommandSpecs(),
|
|
1410
|
+
planContent,
|
|
1411
|
+
});
|
|
1412
|
+
await ken.prompt(digest);
|
|
1413
|
+
if (autopilotCancelled || planGeneration !== genAtStart)
|
|
1414
|
+
return null;
|
|
1415
|
+
return parseAutopilotVerdict(lastAssistantText(ken.getMessages()));
|
|
1416
|
+
}
|
|
1417
|
+
catch (err) {
|
|
1418
|
+
// User action mid-review (manual Accept aborts the nolanAuto run): drop
|
|
1419
|
+
// the review silently — the user's decision supersedes Nolan's.
|
|
1420
|
+
if (autopilotCancelled || planGeneration !== genAtStart)
|
|
1421
|
+
return null;
|
|
1422
|
+
broadcastError("autopilot_error", "autopilot plan review failed", err);
|
|
1423
|
+
return null;
|
|
1424
|
+
}
|
|
1425
|
+
finally {
|
|
1426
|
+
autopilotReviewing = false;
|
|
1427
|
+
// Apply any model switch that landed mid-review.
|
|
1428
|
+
const pending = pendingNolanAutoModel;
|
|
1429
|
+
pendingNolanAutoModel = null;
|
|
1430
|
+
if (pending)
|
|
1431
|
+
await syncNolanAutoModel(pending.provider, pending.model);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
// The prompt fed to the fresh session after a plan is accepted — the SAME
|
|
1435
|
+
// string the webview sends on a manual Accept (see PlanReviewModal's accept
|
|
1436
|
+
// handler in ezcoder-app/src/App.tsx). Keep the two in lockstep so auto- and
|
|
1437
|
+
// manual approval produce identical implementation turns.
|
|
1438
|
+
const IMPLEMENT_PLAN_PROMPT = "The plan has been approved. Implement it now, following each step in order.";
|
|
1097
1439
|
// Drive the review→prompt→review loop for one finished user turn. Only ever
|
|
1098
1440
|
// called after shouldStartAutopilotCycle approves the turn (POST /prompt or
|
|
1099
1441
|
// the stranded-queue drain) — never from the task runner, resume, /ken, or
|
|
@@ -1104,14 +1446,61 @@ async function createSession(deps, opts) {
|
|
|
1104
1446
|
if (!autopilot || autopilotCancelled)
|
|
1105
1447
|
return;
|
|
1106
1448
|
autopilotActive = true;
|
|
1449
|
+
// Generation captured by the last plan review; acceptPlan re-checks it so
|
|
1450
|
+
// a user Accept/Reject landing mid-review always wins.
|
|
1451
|
+
let planGenAtReview = -1;
|
|
1107
1452
|
try {
|
|
1108
1453
|
await driveAutopilotCycle({
|
|
1109
|
-
|
|
1454
|
+
// A plan-pending cycle needs extra rounds: approve+implement and the
|
|
1455
|
+
// post-implement work review each consume one, so +2 keeps a real fix
|
|
1456
|
+
// round available.
|
|
1457
|
+
maxRounds: pendingPlanPath !== null ? MAX_AUTOPILOT_ROUNDS + 2 : MAX_AUTOPILOT_ROUNDS,
|
|
1110
1458
|
isCancelled: () => autopilotCancelled,
|
|
1111
|
-
// An injected run entering plan mode
|
|
1112
|
-
//
|
|
1113
|
-
// plan-mode session
|
|
1459
|
+
// An injected run entering plan mode WITHOUT submitting (enter_plan,
|
|
1460
|
+
// no exit_plan) halts the cycle — Nolan never prompts into a read-only
|
|
1461
|
+
// plan-mode session. A submitted plan takes the planPending branch.
|
|
1114
1462
|
isPlanMode: () => session.getPlanMode(),
|
|
1463
|
+
planPending: () => pendingPlanPath !== null,
|
|
1464
|
+
reviewPlan: async () => {
|
|
1465
|
+
planGenAtReview = planGeneration;
|
|
1466
|
+
return runAutopilotPlanReview(originalRequest);
|
|
1467
|
+
},
|
|
1468
|
+
// Auto-accept: the inlined POST /plan/accept body. Returns false when
|
|
1469
|
+
// the plan generation moved since the review (user acted) — the cycle
|
|
1470
|
+
// exits silently and the user's action stands.
|
|
1471
|
+
acceptPlan: async () => {
|
|
1472
|
+
if (pendingPlanPath === null || planGeneration !== planGenAtReview)
|
|
1473
|
+
return false;
|
|
1474
|
+
const planPath = pendingPlanPath;
|
|
1475
|
+
try {
|
|
1476
|
+
await session.newSession();
|
|
1477
|
+
injectedAutopilotPrompts = [];
|
|
1478
|
+
titleGenerated = false;
|
|
1479
|
+
await session.setApprovedPlan(planPath);
|
|
1480
|
+
}
|
|
1481
|
+
catch (err) {
|
|
1482
|
+
broadcastError("autopilot_error", "autopilot plan accept failed", err);
|
|
1483
|
+
return false;
|
|
1484
|
+
}
|
|
1485
|
+
clearPendingPlan();
|
|
1486
|
+
// Ordering is load-bearing: the webview reads its still-open plan
|
|
1487
|
+
// modal state (step count) on autopilot_plan_accepted, and
|
|
1488
|
+
// session_reset clears it — accepted must land first.
|
|
1489
|
+
broadcast("autopilot_plan_accepted", {});
|
|
1490
|
+
broadcast("session_reset", {});
|
|
1491
|
+
// Persisted into the NEW session so a resume shows the marker.
|
|
1492
|
+
void session.persistAutopilotMarker("plan_approved");
|
|
1493
|
+
return true;
|
|
1494
|
+
},
|
|
1495
|
+
runImplement: () => {
|
|
1496
|
+
// Autopilot-injected run: frame it so EZ Coder knows no human is
|
|
1497
|
+
// watching the implementation. Record the framed string so Nolan's
|
|
1498
|
+
// digest labels it as injected, not as the user's ask. The run_start
|
|
1499
|
+
// label stays the clean prompt.
|
|
1500
|
+
const framed = frameAutopilotInjection(IMPLEMENT_PLAN_PROMPT);
|
|
1501
|
+
injectedAutopilotPrompts.push(framed);
|
|
1502
|
+
return runAgent(IMPLEMENT_PLAN_PROMPT, () => session.prompt(framed));
|
|
1503
|
+
},
|
|
1115
1504
|
// Lean context per user turn: wipe prior review history so each new
|
|
1116
1505
|
// turn starts cheap, while within this cycle the few review messages
|
|
1117
1506
|
// persist so Nolan remembers what he already asked EZ Coder to fix.
|
|
@@ -1125,11 +1514,47 @@ async function createSession(deps, opts) {
|
|
|
1125
1514
|
// streams normally; the shared finally never re-triggers autopilot,
|
|
1126
1515
|
// so this can't recurse.
|
|
1127
1516
|
onInjected: (body, round) => {
|
|
1128
|
-
|
|
1517
|
+
// A revision injection supersedes the pending plan — if the run
|
|
1518
|
+
// resubmits via exit_plan, onExitPlan re-sets it (no-op for work-
|
|
1519
|
+
// branch injections, where nothing is pending).
|
|
1520
|
+
clearPendingPlan();
|
|
1521
|
+
// Record the FRAMED string (what actually lands in the build session,
|
|
1522
|
+
// see runPrompt) so Nolan's digest matches and labels it as injected.
|
|
1523
|
+
// The webview marker + persisted body stay the CLEAN prompt so the UI
|
|
1524
|
+
// shows Nolan's actual instruction, not the autopilot preamble.
|
|
1525
|
+
injectedAutopilotPrompts.push(frameAutopilotInjection(body));
|
|
1129
1526
|
broadcast("autopilot_prompted", { round, body });
|
|
1527
|
+
void session.persistAutopilotMarker("prompted", { body });
|
|
1528
|
+
},
|
|
1529
|
+
// Autopilot-injected run: EZ Coder receives the framed prompt (no human
|
|
1530
|
+
// is watching this turn) while run_start keeps the clean label.
|
|
1531
|
+
runPrompt: (body) => runAgent(body, () => session.prompt(frameAutopilotInjection(body))),
|
|
1532
|
+
emit: (event) => {
|
|
1533
|
+
// Persist the terminal verdict marker so a resumed session renders the
|
|
1534
|
+
// same Nolan bubble the live run showed instead of dropping it or
|
|
1535
|
+
// falling back to the raw verdict text (e.g. ALL_CLEAR).
|
|
1536
|
+
if (event.type === "autopilot_done") {
|
|
1537
|
+
// Broadcast the SAME copySeed the persisted marker will produce on
|
|
1538
|
+
// resume, so the live all-clear wording matches the resumed one
|
|
1539
|
+
// (computed before persist — same synchronous message count).
|
|
1540
|
+
const seed = autopilotMarkerCopySeed({
|
|
1541
|
+
version: 1,
|
|
1542
|
+
phase: "done",
|
|
1543
|
+
afterMessageCount: session.getMessages().filter((m) => m.role !== "system").length,
|
|
1544
|
+
});
|
|
1545
|
+
broadcast(event.type, { ...event.data, copySeed: seed });
|
|
1546
|
+
void session.persistAutopilotMarker("done");
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
broadcast(event.type, event.data);
|
|
1550
|
+
if (event.type === "autopilot_human") {
|
|
1551
|
+
void session.persistAutopilotMarker("human", { reason: event.data.reason });
|
|
1552
|
+
}
|
|
1553
|
+
else if (event.type === "autopilot_capped") {
|
|
1554
|
+
void session.persistAutopilotMarker("capped");
|
|
1555
|
+
}
|
|
1556
|
+
// autopilot_ignored renders nothing live, so nothing is persisted either.
|
|
1130
1557
|
},
|
|
1131
|
-
runPrompt: (body) => runAgent(body, () => session.prompt(body)),
|
|
1132
|
-
emit: (event) => broadcast(event.type, event.data),
|
|
1133
1558
|
});
|
|
1134
1559
|
}
|
|
1135
1560
|
finally {
|
|
@@ -1160,9 +1585,13 @@ async function createSession(deps, opts) {
|
|
|
1160
1585
|
broadcast("queued", { count: session.getQueuedCount() });
|
|
1161
1586
|
if (!next.text.trim() && next.attachments.length === 0)
|
|
1162
1587
|
continue;
|
|
1588
|
+
// A queued message draining as a fresh turn supersedes any pending
|
|
1589
|
+
// plan, exactly like a direct POST /prompt turn.
|
|
1590
|
+
clearPendingPlan();
|
|
1163
1591
|
const workflowCommand = next.attachments.length === 0 &&
|
|
1164
1592
|
isWorkflowCommandText(next.text, await loadWorkflowCommandSpecs());
|
|
1165
1593
|
const assistantsBefore = countAssistantMessages(session.getMessages());
|
|
1594
|
+
const messagesBefore = session.getMessages().length;
|
|
1166
1595
|
await runAgent(next.text, async () => {
|
|
1167
1596
|
if (next.attachments.length > 0) {
|
|
1168
1597
|
await session.promptWithAttachments(next.text, next.attachments);
|
|
@@ -1175,10 +1604,21 @@ async function createSession(deps, opts) {
|
|
|
1175
1604
|
enabled: autopilot,
|
|
1176
1605
|
cancelled: autopilotCancelled,
|
|
1177
1606
|
planMode: session.getPlanMode(),
|
|
1607
|
+
// A submitted plan (exit_plan fired) routes into the PLAN review
|
|
1608
|
+
// branch — the cycle reviews the plan itself instead of skipping.
|
|
1609
|
+
planPending: pendingPlanPath !== null,
|
|
1178
1610
|
workflowCommand,
|
|
1179
1611
|
assistantMessagesAdded: countAssistantMessages(session.getMessages()) - assistantsBefore,
|
|
1612
|
+
// Skip the review API call outright for turns that only started a
|
|
1613
|
+
// background process (dev server/watcher), ran a read-only lookup, or
|
|
1614
|
+
// committed/pushed — Nolan's autopilot contract already IGNOREs these,
|
|
1615
|
+
// so there's no reason to pay for that verdict.
|
|
1616
|
+
mechanicalOnly: isMechanicalOnlyTurn(extractTurnToolCalls(session.getMessages(), messagesBefore)),
|
|
1180
1617
|
});
|
|
1181
1618
|
if (decision.start) {
|
|
1619
|
+
log("INFO", "app-sidecar", "autopilot cycle starting (queued turn)", {
|
|
1620
|
+
kind: decision.kind,
|
|
1621
|
+
});
|
|
1182
1622
|
await runAutopilotCycle(next.text);
|
|
1183
1623
|
}
|
|
1184
1624
|
else if (autopilot) {
|
|
@@ -1197,18 +1637,34 @@ async function createSession(deps, opts) {
|
|
|
1197
1637
|
// completion hint instructing the agent to mark the task done via the tasks
|
|
1198
1638
|
// tool. Run-all advances to the next pending task after each run finishes.
|
|
1199
1639
|
let taskRunAll = false;
|
|
1640
|
+
// True for the duration of any task run (single or run-all). The plan-mode
|
|
1641
|
+
// callbacks read this to decline plan mode when no human is present to
|
|
1642
|
+
// approve a plan, so tasks implement directly instead of hanging on review.
|
|
1643
|
+
let taskRunActive = false;
|
|
1200
1644
|
async function runTaskById(taskId) {
|
|
1201
1645
|
const task = loadTasksSync(cwd).find((t) => t.id === taskId || t.id.startsWith(taskId));
|
|
1202
1646
|
if (!task)
|
|
1203
1647
|
return false;
|
|
1648
|
+
taskRunActive = true;
|
|
1649
|
+
try {
|
|
1650
|
+
return await runTaskByIdInner(task);
|
|
1651
|
+
}
|
|
1652
|
+
finally {
|
|
1653
|
+
taskRunActive = false;
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
async function runTaskByIdInner(task) {
|
|
1204
1657
|
// Fresh session per task so one task's context never bleeds into the next.
|
|
1205
1658
|
await session.newSession();
|
|
1206
1659
|
injectedAutopilotPrompts = [];
|
|
1660
|
+
clearPendingPlan();
|
|
1207
1661
|
titleGenerated = false;
|
|
1208
1662
|
broadcast("session_reset", {});
|
|
1209
1663
|
markTaskInProgress(cwd, task.id);
|
|
1210
1664
|
broadcast("tasks_list", { tasks: loadTasksSync(cwd) });
|
|
1211
1665
|
broadcast("task_start", { id: task.id, title: task.title });
|
|
1666
|
+
// Persist the task header so a resumed task session shows what ran.
|
|
1667
|
+
void session.persistAppMarker("task", { title: task.title }).catch(() => { });
|
|
1212
1668
|
const shortId = task.id.slice(0, 8);
|
|
1213
1669
|
const completionHint = `\n\n---\nWhen you have fully completed this task, call the tasks tool to mark it done:\n` +
|
|
1214
1670
|
`tasks({ action: "done", id: "${shortId}" })`;
|
|
@@ -1319,6 +1775,10 @@ async function createSession(deps, opts) {
|
|
|
1319
1775
|
});
|
|
1320
1776
|
return;
|
|
1321
1777
|
}
|
|
1778
|
+
if (method === "GET" && url === "/progress") {
|
|
1779
|
+
json(res, 200, progress.snapshot());
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1322
1782
|
if (method === "GET" && (url === "/events" || url.startsWith("/events?"))) {
|
|
1323
1783
|
res.writeHead(200, {
|
|
1324
1784
|
"content-type": "text/event-stream",
|
|
@@ -1498,8 +1958,11 @@ async function createSession(deps, opts) {
|
|
|
1498
1958
|
// they were recorded after, so each lands right after that message. A
|
|
1499
1959
|
// turn becomes two wire rows: the `@Nolan` question (user) + Nolan's reply
|
|
1500
1960
|
// (assistant), both flagged `ken` so the webview tints them.
|
|
1961
|
+
// Deduped; stale anchors are clamped to the last message (Nolan turns
|
|
1962
|
+
// carry real conversation, so they render at the end instead of
|
|
1963
|
+
// vanishing).
|
|
1501
1964
|
const nolanByCount = new Map();
|
|
1502
|
-
for (const turn of session.getNolanTurns()) {
|
|
1965
|
+
for (const turn of normalizeNolanTurnsForHistory(session.getNolanTurns(), messages.filter((m) => m.role !== "system").length)) {
|
|
1503
1966
|
const list = nolanByCount.get(turn.afterMessageCount) ?? [];
|
|
1504
1967
|
list.push(turn);
|
|
1505
1968
|
nolanByCount.set(turn.afterMessageCount, list);
|
|
@@ -1514,9 +1977,103 @@ async function createSession(deps, opts) {
|
|
|
1514
1977
|
history.push({ role: "assistant", text: turn.reply, ken: true });
|
|
1515
1978
|
}
|
|
1516
1979
|
};
|
|
1980
|
+
// Autopilot verdict markers to interleave, same anchor scheme as Nolan
|
|
1981
|
+
// turns — each becomes a single assistant row the webview renders
|
|
1982
|
+
// exactly like the live `autopilot` item (never a raw verdict string).
|
|
1983
|
+
// Compact/continuation rewrites can carry old markers whose original
|
|
1984
|
+
// afterMessageCount is beyond the restored message list; dropping those
|
|
1985
|
+
// prevents stale all-clear bubbles from bunching at the bottom on resume.
|
|
1986
|
+
const restoredMessageCount = messages.filter((m) => m.role !== "system").length;
|
|
1987
|
+
const autopilotByCount = new Map();
|
|
1988
|
+
for (const marker of normalizeAutopilotMarkersForHistory(session.getAutopilotMarkers(), restoredMessageCount)) {
|
|
1989
|
+
const list = autopilotByCount.get(marker.afterMessageCount) ?? [];
|
|
1990
|
+
list.push(marker);
|
|
1991
|
+
autopilotByCount.set(marker.afterMessageCount, list);
|
|
1992
|
+
}
|
|
1993
|
+
const flushAutopilot = (count) => {
|
|
1994
|
+
const markers = autopilotByCount.get(count);
|
|
1995
|
+
if (!markers)
|
|
1996
|
+
return;
|
|
1997
|
+
autopilotByCount.delete(count);
|
|
1998
|
+
for (const marker of markers) {
|
|
1999
|
+
history.push({
|
|
2000
|
+
role: "assistant",
|
|
2001
|
+
text: "",
|
|
2002
|
+
autopilot: {
|
|
2003
|
+
phase: marker.phase,
|
|
2004
|
+
...(marker.reason !== undefined ? { reason: marker.reason } : {}),
|
|
2005
|
+
...(marker.body !== undefined ? { body: marker.body } : {}),
|
|
2006
|
+
copySeed: marker.copySeed,
|
|
2007
|
+
},
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
};
|
|
2011
|
+
// App transcript markers (plan banner / task header / error rows /
|
|
2012
|
+
// user-bubble hints), same anchor scheme. user_hint markers don't
|
|
2013
|
+
// become rows — they decorate the user row at their anchor instead.
|
|
2014
|
+
const appMarkersByCount = new Map();
|
|
2015
|
+
const userHintByCount = new Map();
|
|
2016
|
+
// Compaction-count markers pair with compacted summary rows in file
|
|
2017
|
+
// order (FIFO), not by anchor — the summary user message is what
|
|
2018
|
+
// positions the notice.
|
|
2019
|
+
const compactionCounts = [];
|
|
2020
|
+
for (const marker of normalizeAppMarkersForHistory(session.getAppMarkers(), restoredMessageCount)) {
|
|
2021
|
+
if (marker.kind === "user_hint") {
|
|
2022
|
+
userHintByCount.set(marker.afterMessageCount, marker.data);
|
|
2023
|
+
continue;
|
|
2024
|
+
}
|
|
2025
|
+
if (marker.kind === "compaction") {
|
|
2026
|
+
const d = marker.data;
|
|
2027
|
+
if (typeof d.originalCount === "number" && typeof d.newCount === "number") {
|
|
2028
|
+
compactionCounts.push({ originalCount: d.originalCount, newCount: d.newCount });
|
|
2029
|
+
}
|
|
2030
|
+
continue;
|
|
2031
|
+
}
|
|
2032
|
+
const list = appMarkersByCount.get(marker.afterMessageCount) ?? [];
|
|
2033
|
+
list.push(marker);
|
|
2034
|
+
appMarkersByCount.set(marker.afterMessageCount, list);
|
|
2035
|
+
}
|
|
2036
|
+
const flushAppMarkers = (count) => {
|
|
2037
|
+
const markers = appMarkersByCount.get(count);
|
|
2038
|
+
if (!markers)
|
|
2039
|
+
return;
|
|
2040
|
+
appMarkersByCount.delete(count);
|
|
2041
|
+
for (const marker of markers) {
|
|
2042
|
+
const d = marker.data;
|
|
2043
|
+
if (marker.kind === "plan") {
|
|
2044
|
+
history.push({
|
|
2045
|
+
role: "assistant",
|
|
2046
|
+
text: "",
|
|
2047
|
+
plan: { reason: typeof d.reason === "string" ? d.reason : "" },
|
|
2048
|
+
});
|
|
2049
|
+
}
|
|
2050
|
+
else if (marker.kind === "task") {
|
|
2051
|
+
history.push({
|
|
2052
|
+
role: "assistant",
|
|
2053
|
+
text: "",
|
|
2054
|
+
task: { title: typeof d.title === "string" ? d.title : "" },
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
else if (marker.kind === "error" && typeof d.headline === "string") {
|
|
2058
|
+
history.push({
|
|
2059
|
+
role: "assistant",
|
|
2060
|
+
text: "",
|
|
2061
|
+
error: {
|
|
2062
|
+
scope: typeof d.scope === "string" ? d.scope : "error",
|
|
2063
|
+
headline: d.headline,
|
|
2064
|
+
...(typeof d.message === "string" ? { message: d.message } : {}),
|
|
2065
|
+
...(typeof d.guidance === "string" ? { guidance: d.guidance } : {}),
|
|
2066
|
+
},
|
|
2067
|
+
});
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
};
|
|
1517
2071
|
let nonSystemCount = 0;
|
|
1518
|
-
// Turns recorded before any build message (anchor 0) render at
|
|
2072
|
+
// Turns/markers recorded before any build message (anchor 0) render at
|
|
2073
|
+
// the top.
|
|
1519
2074
|
flushNolan(0);
|
|
2075
|
+
flushAutopilot(0);
|
|
2076
|
+
flushAppMarkers(0);
|
|
1520
2077
|
for (const msg of messages) {
|
|
1521
2078
|
if (msg.role === "system")
|
|
1522
2079
|
continue;
|
|
@@ -1566,30 +2123,54 @@ async function createSession(deps, opts) {
|
|
|
1566
2123
|
}
|
|
1567
2124
|
continue;
|
|
1568
2125
|
}
|
|
1569
|
-
// User or assistant message —
|
|
1570
|
-
//
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
2126
|
+
// User or assistant message — text/hook/command/compacted extraction,
|
|
2127
|
+
// plus sub-agent group detection for assistant tool_calls.
|
|
2128
|
+
if (msg.role === "user") {
|
|
2129
|
+
// Rebuild the live bubble: strip the steering wrapper, drop
|
|
2130
|
+
// attachment/file notes the model saw but the bubble never showed.
|
|
2131
|
+
const restored = restoreUserRow(msg.content);
|
|
2132
|
+
const text = restored.text;
|
|
2133
|
+
const hook = detectHookKind(text);
|
|
2134
|
+
const compacted = !hook && text.startsWith("[Previous conversation summary]");
|
|
2135
|
+
const command = !hook && !compacted ? detectPromptCommand(text, commandCandidates) : null;
|
|
2136
|
+
if (text.trim() || restored.images.length > 0) {
|
|
2137
|
+
const hint = userHintByCount.get(nonSystemCount);
|
|
2138
|
+
history.push({
|
|
2139
|
+
role: "user",
|
|
2140
|
+
text: command ?? text,
|
|
2141
|
+
images: restored.images,
|
|
2142
|
+
hook,
|
|
2143
|
+
command: command !== null,
|
|
2144
|
+
compacted,
|
|
2145
|
+
// Markers accumulate across continuation files (each rewrite
|
|
2146
|
+
// re-persists prior ones) but only the LATEST summary row
|
|
2147
|
+
// survives compaction — so consume from the newest end.
|
|
2148
|
+
...(compacted && compactionCounts.length > 0
|
|
2149
|
+
? { compactionCounts: compactionCounts.pop() }
|
|
2150
|
+
: {}),
|
|
2151
|
+
...(hint?.nolanSent === true ? { nolanSent: true } : {}),
|
|
2152
|
+
...(Array.isArray(hint?.enhancements) ? { enhancements: hint.enhancements } : {}),
|
|
2153
|
+
});
|
|
2154
|
+
// Live showed the video-capability warning right after the bubble.
|
|
2155
|
+
if (restored.videoWarning) {
|
|
2156
|
+
history.push({ role: "assistant", text: "", infoKind: "video_warning" });
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
else {
|
|
2161
|
+
// Assistant: one wire row per persisted text block — live streaming
|
|
2162
|
+
// splits bubbles at server_tool_call boundaries, and the persisted
|
|
2163
|
+
// content keeps those blocks separate.
|
|
2164
|
+
for (const blockText of restoreAssistantTexts(msg.content)) {
|
|
2165
|
+
history.push({
|
|
2166
|
+
role: "assistant",
|
|
2167
|
+
text: blockText,
|
|
2168
|
+
images: [],
|
|
2169
|
+
hook: null,
|
|
2170
|
+
command: false,
|
|
2171
|
+
compacted: false,
|
|
2172
|
+
});
|
|
2173
|
+
}
|
|
1593
2174
|
}
|
|
1594
2175
|
// Assistant tool_call blocks: detect sub-agent delegations.
|
|
1595
2176
|
if (msg.role === "assistant" && typeof msg.content !== "string") {
|
|
@@ -1610,14 +2191,21 @@ async function createSession(deps, opts) {
|
|
|
1610
2191
|
});
|
|
1611
2192
|
}
|
|
1612
2193
|
}
|
|
1613
|
-
// Interleave any Nolan turns
|
|
2194
|
+
// Interleave any Nolan turns / autopilot / app markers recorded right
|
|
2195
|
+
// after this message.
|
|
1614
2196
|
flushNolan(nonSystemCount);
|
|
2197
|
+
flushAutopilot(nonSystemCount);
|
|
2198
|
+
flushAppMarkers(nonSystemCount);
|
|
1615
2199
|
}
|
|
1616
|
-
// Flush remaining Nolan turns whose anchor is at/after the message count
|
|
1617
|
-
//
|
|
1618
|
-
// count
|
|
2200
|
+
// Flush remaining Nolan turns whose anchor is at/after the message count so
|
|
2201
|
+
// none are dropped. Autopilot/app markers beyond the restored message
|
|
2202
|
+
// count were already filtered above; any remaining marker here is valid.
|
|
1619
2203
|
for (const count of [...nolanByCount.keys()].sort((a, b) => a - b))
|
|
1620
2204
|
flushNolan(count);
|
|
2205
|
+
for (const count of [...autopilotByCount.keys()].sort((a, b) => a - b))
|
|
2206
|
+
flushAutopilot(count);
|
|
2207
|
+
for (const count of [...appMarkersByCount.keys()].sort((a, b) => a - b))
|
|
2208
|
+
flushAppMarkers(count);
|
|
1621
2209
|
json(res, 200, { history });
|
|
1622
2210
|
})();
|
|
1623
2211
|
return;
|
|
@@ -1650,10 +2238,12 @@ async function createSession(deps, opts) {
|
|
|
1650
2238
|
void readBody(req).then(async (raw) => {
|
|
1651
2239
|
let text;
|
|
1652
2240
|
let attachments;
|
|
2241
|
+
let meta;
|
|
1653
2242
|
try {
|
|
1654
2243
|
const body = JSON.parse(raw);
|
|
1655
2244
|
text = body.text ?? "";
|
|
1656
2245
|
attachments = Array.isArray(body.attachments) ? body.attachments : [];
|
|
2246
|
+
meta = typeof body.meta === "object" && body.meta !== null ? body.meta : undefined;
|
|
1657
2247
|
}
|
|
1658
2248
|
catch {
|
|
1659
2249
|
json(res, 400, { error: "invalid JSON body" });
|
|
@@ -1677,9 +2267,25 @@ async function createSession(deps, opts) {
|
|
|
1677
2267
|
return;
|
|
1678
2268
|
}
|
|
1679
2269
|
json(res, 202, { accepted: true });
|
|
2270
|
+
// Webview display hint for this prompt's user bubble (nolanSent shimmer
|
|
2271
|
+
// label / enhancer highlight segments). Anchored +1 so it attaches to
|
|
2272
|
+
// the user message the prompt below is about to push. Queued prompts
|
|
2273
|
+
// skip this (their position in the run is unpredictable).
|
|
2274
|
+
if (meta && (meta.nolanSent === true || Array.isArray(meta.enhancements))) {
|
|
2275
|
+
void session
|
|
2276
|
+
.persistAppMarker("user_hint", {
|
|
2277
|
+
...(meta.nolanSent === true ? { nolanSent: true } : {}),
|
|
2278
|
+
...(Array.isArray(meta.enhancements) ? { enhancements: meta.enhancements } : {}),
|
|
2279
|
+
}, 1)
|
|
2280
|
+
.catch(() => { });
|
|
2281
|
+
}
|
|
1680
2282
|
// Fresh user turn: clear any cancel flag left from a prior cycle so this
|
|
1681
2283
|
// turn's autopilot review can run.
|
|
1682
2284
|
autopilotCancelled = false;
|
|
2285
|
+
// A typed message while a plan modal/review is pending (reject,
|
|
2286
|
+
// feedback, anything) supersedes the pending plan — the bump also
|
|
2287
|
+
// invalidates any in-flight Nolan plan review.
|
|
2288
|
+
clearPendingPlan();
|
|
1683
2289
|
// Gate inputs captured around the run: whether this turn is a workflow
|
|
1684
2290
|
// slash command (attachment prompts skip slash expansion entirely), and
|
|
1685
2291
|
// how many assistant messages the run actually adds. Computed even when
|
|
@@ -1687,6 +2293,7 @@ async function createSession(deps, opts) {
|
|
|
1687
2293
|
// gate reads the post-run value.
|
|
1688
2294
|
const workflowCommand = attachments.length === 0 && isWorkflowCommandText(text, await loadWorkflowCommandSpecs());
|
|
1689
2295
|
const assistantsBefore = countAssistantMessages(session.getMessages());
|
|
2296
|
+
const messagesBefore = session.getMessages().length;
|
|
1690
2297
|
await runAgent(text, async () => {
|
|
1691
2298
|
if (attachments.length > 0) {
|
|
1692
2299
|
// Persist each attachment under .ezcoder/uploads so files are inspectable
|
|
@@ -1715,10 +2322,19 @@ async function createSession(deps, opts) {
|
|
|
1715
2322
|
enabled: autopilot,
|
|
1716
2323
|
cancelled: autopilotCancelled,
|
|
1717
2324
|
planMode: session.getPlanMode(),
|
|
2325
|
+
// A submitted plan (exit_plan fired) routes into the PLAN review
|
|
2326
|
+
// branch — the cycle reviews the plan itself instead of skipping.
|
|
2327
|
+
planPending: pendingPlanPath !== null,
|
|
1718
2328
|
workflowCommand,
|
|
1719
2329
|
assistantMessagesAdded: countAssistantMessages(session.getMessages()) - assistantsBefore,
|
|
2330
|
+
// Skip the review API call outright for turns that only started a
|
|
2331
|
+
// background process (dev server/watcher), ran a read-only lookup, or
|
|
2332
|
+
// committed/pushed — Nolan's autopilot contract already IGNOREs these,
|
|
2333
|
+
// so there's no reason to pay for that verdict.
|
|
2334
|
+
mechanicalOnly: isMechanicalOnlyTurn(extractTurnToolCalls(session.getMessages(), messagesBefore)),
|
|
1720
2335
|
});
|
|
1721
2336
|
if (decision.start) {
|
|
2337
|
+
log("INFO", "app-sidecar", "autopilot cycle starting", { kind: decision.kind });
|
|
1722
2338
|
await runAutopilotCycle(text);
|
|
1723
2339
|
}
|
|
1724
2340
|
else if (autopilot) {
|
|
@@ -2175,12 +2791,14 @@ async function createSession(deps, opts) {
|
|
|
2175
2791
|
return;
|
|
2176
2792
|
}
|
|
2177
2793
|
if (method === "POST" && url === "/cancel") {
|
|
2794
|
+
cancelGeneration++;
|
|
2178
2795
|
abort.abort();
|
|
2179
2796
|
abort = new AbortController();
|
|
2180
2797
|
session.setSignal(abort.signal);
|
|
2181
2798
|
running = false;
|
|
2182
2799
|
// Stop a run-all sweep so the next pending task isn't auto-started.
|
|
2183
2800
|
taskRunAll = false;
|
|
2801
|
+
taskRunActive = false;
|
|
2184
2802
|
// Stop any in-flight autopilot cycle: flag it so the loop bails between
|
|
2185
2803
|
// steps, and abort a review that's mid-prompt on the nolanAuto session.
|
|
2186
2804
|
autopilotCancelled = true;
|
|
@@ -2205,6 +2823,7 @@ async function createSession(deps, opts) {
|
|
|
2205
2823
|
.newSession()
|
|
2206
2824
|
.then(() => {
|
|
2207
2825
|
injectedAutopilotPrompts = [];
|
|
2826
|
+
clearPendingPlan();
|
|
2208
2827
|
broadcast("session_reset", {});
|
|
2209
2828
|
json(res, 200, { ok: true });
|
|
2210
2829
|
})
|
|
@@ -2236,6 +2855,24 @@ async function createSession(deps, opts) {
|
|
|
2236
2855
|
json(res, 409, { error: "cannot accept a plan while the agent is running" });
|
|
2237
2856
|
return;
|
|
2238
2857
|
}
|
|
2858
|
+
// Manual accept, possibly racing Nolan's autopilot plan review: the user
|
|
2859
|
+
// always wins. Bump the plan generation (invalidates any in-flight
|
|
2860
|
+
// review's verdict), stop the cycle, abort a mid-prompt review on the
|
|
2861
|
+
// nolanAuto session, and clear the spinner — autopilot_ignored renders
|
|
2862
|
+
// nothing, so no stale "approve or reject" bubble ever lands. The
|
|
2863
|
+
// webview's follow-up "implement" /prompt arrives as a fresh turn
|
|
2864
|
+
// (resetting autopilotCancelled), so the implementation still gets its
|
|
2865
|
+
// normal post-run review; if it lands while the cycle is winding down
|
|
2866
|
+
// it queues and runStrandedQueue drains it as a fresh turn.
|
|
2867
|
+
clearPendingPlan();
|
|
2868
|
+
autopilotCancelled = true;
|
|
2869
|
+
nolanAutoAbort.abort();
|
|
2870
|
+
nolanAutoAbort = new AbortController();
|
|
2871
|
+
nolanAutoSession?.setSignal(nolanAutoAbort.signal);
|
|
2872
|
+
if (autopilotReviewing) {
|
|
2873
|
+
autopilotReviewing = false;
|
|
2874
|
+
broadcast("autopilot_ignored", {});
|
|
2875
|
+
}
|
|
2239
2876
|
try {
|
|
2240
2877
|
await session.newSession();
|
|
2241
2878
|
injectedAutopilotPrompts = [];
|