@prestyj/cli 5.2.0 → 5.3.1
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 +856 -91
- package/dist/app-sidecar.js.map +1 -1
- package/dist/core/agent-session-compaction.test.js +89 -0
- package/dist/core/agent-session-compaction.test.js.map +1 -1
- package/dist/core/agent-session-queue.test.d.ts +2 -0
- package/dist/core/agent-session-queue.test.d.ts.map +1 -0
- package/dist/core/agent-session-queue.test.js +122 -0
- package/dist/core/agent-session-queue.test.js.map +1 -0
- package/dist/core/agent-session.d.ts +79 -3
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +268 -47
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/autopilot-cycle.d.ts +119 -0
- package/dist/core/autopilot-cycle.d.ts.map +1 -0
- package/dist/core/autopilot-cycle.js +119 -0
- package/dist/core/autopilot-cycle.js.map +1 -0
- package/dist/core/autopilot-cycle.test.d.ts +2 -0
- package/dist/core/autopilot-cycle.test.d.ts.map +1 -0
- package/dist/core/autopilot-cycle.test.js +375 -0
- package/dist/core/autopilot-cycle.test.js.map +1 -0
- package/dist/core/autopilot-gate.d.ts +109 -0
- package/dist/core/autopilot-gate.d.ts.map +1 -0
- package/dist/core/autopilot-gate.js +205 -0
- package/dist/core/autopilot-gate.js.map +1 -0
- package/dist/core/autopilot-gate.test.d.ts +2 -0
- package/dist/core/autopilot-gate.test.d.ts.map +1 -0
- package/dist/core/autopilot-gate.test.js +295 -0
- package/dist/core/autopilot-gate.test.js.map +1 -0
- package/dist/core/autopilot-verdict.d.ts +20 -2
- package/dist/core/autopilot-verdict.d.ts.map +1 -1
- package/dist/core/autopilot-verdict.js +94 -3
- package/dist/core/autopilot-verdict.js.map +1 -1
- package/dist/core/autopilot-verdict.test.js +73 -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/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/json-mode-flag-parity.test.d.ts +2 -0
- package/dist/core/json-mode-flag-parity.test.d.ts.map +1 -0
- package/dist/core/json-mode-flag-parity.test.js +66 -0
- package/dist/core/json-mode-flag-parity.test.js.map +1 -0
- 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 +43 -2
- package/dist/core/nolan-context.d.ts.map +1 -1
- package/dist/core/nolan-context.js +91 -8
- package/dist/core/nolan-context.js.map +1 -1
- package/dist/core/nolan-context.test.js +167 -5
- package/dist/core/nolan-context.test.js.map +1 -1
- package/dist/core/nolan-model.d.ts +46 -0
- package/dist/core/nolan-model.d.ts.map +1 -0
- package/dist/core/nolan-model.js +30 -0
- package/dist/core/nolan-model.js.map +1 -0
- package/dist/core/nolan-model.test.d.ts +2 -0
- package/dist/core/nolan-model.test.d.ts.map +1 -0
- package/dist/core/nolan-model.test.js +51 -0
- package/dist/core/nolan-model.test.js.map +1 -0
- package/dist/core/nolan-prompt.d.ts +5 -18
- package/dist/core/nolan-prompt.d.ts.map +1 -1
- package/dist/core/nolan-prompt.js +112 -19
- package/dist/core/nolan-prompt.js.map +1 -1
- package/dist/core/nolan-prompt.test.d.ts +2 -0
- package/dist/core/nolan-prompt.test.d.ts.map +1 -0
- package/dist/core/nolan-prompt.test.js +138 -0
- package/dist/core/nolan-prompt.test.js.map +1 -0
- 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 +7 -0
- package/dist/core/progress/rebuild.d.ts.map +1 -0
- package/dist/core/progress/rebuild.js +106 -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 +72 -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/core/speed-benchmark.test.js +2 -3
- package/dist/core/speed-benchmark.test.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.map +1 -1
- package/dist/tools/subagent.js +24 -1
- package/dist/tools/subagent.js.map +1 -1
- 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/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 +5 -5
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,9 +22,12 @@ 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 {
|
|
27
|
+
import { isWorkflowCommandText, countAssistantMessages, shouldStartAutopilotCycle, extractTurnToolCalls, isMechanicalOnlyTurn, } from "./core/autopilot-gate.js";
|
|
28
|
+
import { driveAutopilotCycle, frameAutopilotInjection } from "./core/autopilot-cycle.js";
|
|
29
|
+
import { validateNolanModelPref, effectiveNolanModel, } from "./core/nolan-model.js";
|
|
30
|
+
import { normalizeAutopilotMarkersForHistory, normalizeAppMarkersForHistory, normalizeNolanTurnsForHistory, restoreUserRow, restoreAssistantTexts, autopilotMarkerCopySeed, } from "./core/session-history.js";
|
|
27
31
|
import { AuthStorage } from "./core/auth-storage.js";
|
|
28
32
|
import { MOONSHOT_OAUTH_KEY, XIAOMI_CREDITS_KEY } from "@prestyj/core";
|
|
29
33
|
import { loginAnthropic } from "./core/oauth/anthropic.js";
|
|
@@ -48,6 +52,11 @@ import { downscaleForPreview, validateVisionImage } from "./utils/image.js";
|
|
|
48
52
|
import { startServeMode } from "./modes/serve-mode.js";
|
|
49
53
|
import { loadTelegramConfig, saveTelegramConfig, verifyBotToken } from "./core/telegram-config.js";
|
|
50
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";
|
|
51
60
|
const ALL_PROVIDERS = [
|
|
52
61
|
"anthropic",
|
|
53
62
|
"xiaomi",
|
|
@@ -82,6 +91,7 @@ async function loadAppSettings() {
|
|
|
82
91
|
// model/thinking handlers below).
|
|
83
92
|
projectModels: raw.projectModels && typeof raw.projectModels === "object" ? raw.projectModels : undefined,
|
|
84
93
|
autopilot: raw.autopilot && typeof raw.autopilot === "object" ? raw.autopilot : undefined,
|
|
94
|
+
nolanModels: raw.nolanModels && typeof raw.nolanModels === "object" ? raw.nolanModels : undefined,
|
|
85
95
|
};
|
|
86
96
|
}
|
|
87
97
|
catch {
|
|
@@ -105,6 +115,24 @@ async function saveProjectModelPrefs(cwd, prefs) {
|
|
|
105
115
|
s.projectModels = { ...(s.projectModels ?? {}), [key]: prefs };
|
|
106
116
|
await saveAppSettings(s);
|
|
107
117
|
}
|
|
118
|
+
/** Read this project's persisted Nolan model override, if any. */
|
|
119
|
+
async function loadNolanModelPref(cwd) {
|
|
120
|
+
const s = await loadAppSettings();
|
|
121
|
+
return s.nolanModels?.[projectModelKey(cwd)];
|
|
122
|
+
}
|
|
123
|
+
/** Persist (or with null, clear) this project's Nolan model override via
|
|
124
|
+
* read-modify-write so the rest of the settings file is preserved. */
|
|
125
|
+
async function saveNolanModelPref(cwd, pref) {
|
|
126
|
+
const s = await loadAppSettings();
|
|
127
|
+
const key = projectModelKey(cwd);
|
|
128
|
+
const next = { ...(s.nolanModels ?? {}) };
|
|
129
|
+
if (pref)
|
|
130
|
+
next[key] = pref;
|
|
131
|
+
else
|
|
132
|
+
delete next[key];
|
|
133
|
+
s.nolanModels = next;
|
|
134
|
+
await saveAppSettings(s);
|
|
135
|
+
}
|
|
108
136
|
/** Read this project's persisted autopilot flag (default off). */
|
|
109
137
|
async function loadAutopilot(cwd) {
|
|
110
138
|
const s = await loadAppSettings();
|
|
@@ -378,12 +406,23 @@ async function runJsonModeIfRequested() {
|
|
|
378
406
|
model: { type: "string" },
|
|
379
407
|
"max-turns": { type: "string" },
|
|
380
408
|
"system-prompt": { type: "string" },
|
|
409
|
+
tools: { type: "string" },
|
|
381
410
|
"prompt-cache-key": { type: "string" },
|
|
382
411
|
},
|
|
383
412
|
allowPositionals: true,
|
|
384
413
|
strict: true,
|
|
385
414
|
});
|
|
386
415
|
const maxTurnsRaw = values["max-turns"];
|
|
416
|
+
// Optional tool allow-list forwarded by the subagent spawner from an agent
|
|
417
|
+
// definition's `tools:` frontmatter. Mirrors the identical parsing in
|
|
418
|
+
// cli.ts's `values.json` branch — keep both in sync (see subagent.ts).
|
|
419
|
+
const parsedTools = values.tools
|
|
420
|
+
? values.tools
|
|
421
|
+
.split(",")
|
|
422
|
+
.map((t) => t.trim())
|
|
423
|
+
.filter(Boolean)
|
|
424
|
+
: [];
|
|
425
|
+
const allowedTools = parsedTools.length > 0 ? parsedTools : undefined;
|
|
387
426
|
await runJsonMode({
|
|
388
427
|
message: positionals[0] ?? "",
|
|
389
428
|
provider: (values.provider ?? "anthropic"),
|
|
@@ -391,6 +430,7 @@ async function runJsonModeIfRequested() {
|
|
|
391
430
|
cwd: process.cwd(),
|
|
392
431
|
systemPrompt: values["system-prompt"],
|
|
393
432
|
maxTurns: maxTurnsRaw ? parseInt(maxTurnsRaw, 10) : undefined,
|
|
433
|
+
allowedTools,
|
|
394
434
|
promptCacheKey: values["prompt-cache-key"],
|
|
395
435
|
}).catch((err) => {
|
|
396
436
|
process.stderr.write((err instanceof Error ? err.message : String(err)) + "\n");
|
|
@@ -462,6 +502,13 @@ async function main() {
|
|
|
462
502
|
// request to its window's session via the `x-gg-session` header (and the
|
|
463
503
|
// `?session=` query for the SSE /events stream).
|
|
464
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
|
+
});
|
|
465
512
|
/** Resolve the target session id: the `x-gg-session` header, else a
|
|
466
513
|
* `?session=` query param (used by the SSE /events connection). */
|
|
467
514
|
function sessionIdFromReq(req, url) {
|
|
@@ -505,7 +552,7 @@ async function main() {
|
|
|
505
552
|
const sessionPath = typeof body.sessionPath === "string" && body.sessionPath ? body.sessionPath : undefined;
|
|
506
553
|
const id = randomUUID();
|
|
507
554
|
try {
|
|
508
|
-
const ctx = await createSession({ auth, paths }, { id, cwd: sessionCwd, sessionPath });
|
|
555
|
+
const ctx = await createSession({ auth, paths, progress }, { id, cwd: sessionCwd, sessionPath });
|
|
509
556
|
sessions.set(id, ctx);
|
|
510
557
|
log("INFO", "app-sidecar", "session created", { id, cwd: sessionCwd });
|
|
511
558
|
daemonJson(res, 200, { sessionId: id });
|
|
@@ -530,6 +577,12 @@ async function main() {
|
|
|
530
577
|
daemonJson(res, 200, { ok: true });
|
|
531
578
|
return;
|
|
532
579
|
}
|
|
580
|
+
// Progress is daemon-level so the Home screen can paint before a project
|
|
581
|
+
// session exists; per-session callers still work through the same endpoint.
|
|
582
|
+
if (method === "GET" && url === "/progress") {
|
|
583
|
+
daemonJson(res, 200, progress.snapshot());
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
533
586
|
// ── Per-session delegation ───────────────────────────────────────────
|
|
534
587
|
const id = sessionIdFromReq(req, url);
|
|
535
588
|
const ctx = id ? sessions.get(id) : undefined;
|
|
@@ -589,20 +642,107 @@ function lastAssistantText(messages) {
|
|
|
589
642
|
return "";
|
|
590
643
|
}
|
|
591
644
|
/**
|
|
592
|
-
* Assemble Nolan's context digest for one `@Nolan` question:
|
|
593
|
-
*
|
|
594
|
-
*
|
|
645
|
+
* Assemble Nolan's context digest for one `@Nolan` question: git/env + the build
|
|
646
|
+
* session's compaction summary + recent activity. Prepended to the user's
|
|
647
|
+
* question as Nolan's prompt body each turn. Project docs (CLAUDE.md/AGENTS.md)
|
|
648
|
+
* are NOT here — they're folded into Nolan's cached system prompt once per
|
|
649
|
+
* session instead (see nolan-prompt.ts), so they hit the provider prompt cache
|
|
650
|
+
* instead of being re-sent uncached on every question. Workflow commands +
|
|
651
|
+
* autopilot-injected prompts are passed through so the digest labels them as
|
|
652
|
+
* what they are instead of user-authored asks.
|
|
595
653
|
*/
|
|
596
|
-
|
|
597
|
-
const projectContext = await collectProjectContext(cwd).catch(() => []);
|
|
654
|
+
function buildNolanContext(buildSession, cwd, gitBranch, question, workflowCommands, injectedPrompts) {
|
|
598
655
|
return buildNolanDigest({
|
|
599
656
|
question,
|
|
600
|
-
projectContext,
|
|
601
657
|
cwd,
|
|
602
658
|
gitBranch,
|
|
603
659
|
messages: buildSession.getMessages(),
|
|
660
|
+
workflowCommands,
|
|
661
|
+
injectedPrompts,
|
|
604
662
|
});
|
|
605
663
|
}
|
|
664
|
+
async function createProgressManager(agentDir, broadcastAll) {
|
|
665
|
+
// Boot: recovery chain main → backup → one-time session-history rebuild → empty.
|
|
666
|
+
let file = await loadProgress({ rebuild: () => rebuildFromSessions() });
|
|
667
|
+
// Don't re-celebrate an old levelUp event on boot.
|
|
668
|
+
let lastSeenNonce = file.lastEvent?.nonce ?? null;
|
|
669
|
+
log("INFO", "app-sidecar", "progress loaded", {
|
|
670
|
+
xp: String(file.xp),
|
|
671
|
+
level: String(levelForXp(file.xp)),
|
|
672
|
+
});
|
|
673
|
+
function snapshot() {
|
|
674
|
+
return buildSnapshot(file);
|
|
675
|
+
}
|
|
676
|
+
async function awardRun(cwd, runStartedAt, originId) {
|
|
677
|
+
try {
|
|
678
|
+
const now = Date.now();
|
|
679
|
+
const updated = await updateProgress(async (f) => {
|
|
680
|
+
const levelBefore = levelForXp(f.xp);
|
|
681
|
+
awardPrompt(f, now, cwd);
|
|
682
|
+
// Commit XP: probe repo root + HEAD, then score lastHead..HEAD bounded
|
|
683
|
+
// by the run window. First sight of a repo records HEAD, scores nothing.
|
|
684
|
+
const probe = await detectNewCommits(cwd, undefined, runStartedAt);
|
|
685
|
+
if (probe) {
|
|
686
|
+
const key = repoKey(probe.repoRoot);
|
|
687
|
+
const lastHead = f.repos[key]?.lastHead;
|
|
688
|
+
if (lastHead && lastHead !== probe.head) {
|
|
689
|
+
const detected = await detectNewCommits(cwd, lastHead, runStartedAt);
|
|
690
|
+
if (detected && detected.commits.length > 0) {
|
|
691
|
+
awardCommits(f, detected.commits, now, cwd);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
f.repos[key] = { lastHead: probe.head };
|
|
695
|
+
}
|
|
696
|
+
// One combined lastEvent per run so other windows celebrate exactly once.
|
|
697
|
+
const levelAfter = levelForXp(f.xp);
|
|
698
|
+
const levelUp = levelAfter > levelBefore
|
|
699
|
+
? { from: levelBefore, to: levelAfter, rankName: rankForLevel(levelAfter).name }
|
|
700
|
+
: null;
|
|
701
|
+
f.lastEvent = { nonce: randomUUID(), levelUp };
|
|
702
|
+
return { file: f, levelledUp: levelUp !== null };
|
|
703
|
+
});
|
|
704
|
+
file = updated;
|
|
705
|
+
lastSeenNonce = updated.lastEvent?.nonce ?? null;
|
|
706
|
+
broadcastAll(buildSnapshot(updated), originId);
|
|
707
|
+
}
|
|
708
|
+
catch (err) {
|
|
709
|
+
log("DEBUG", "app-sidecar", "progress award failed", {
|
|
710
|
+
message: err instanceof Error ? err.message : String(err),
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
// Watch ~/.gg (dir watch survives the atomic tmp+rename) for progress.json
|
|
715
|
+
// writes from other daemon processes; debounce, reload read-only, dedupe by nonce.
|
|
716
|
+
let watchDebounce = null;
|
|
717
|
+
try {
|
|
718
|
+
const watcher = fsWatch(agentDir, (_event, filename) => {
|
|
719
|
+
if (filename !== "progress.json")
|
|
720
|
+
return;
|
|
721
|
+
if (watchDebounce)
|
|
722
|
+
clearTimeout(watchDebounce);
|
|
723
|
+
watchDebounce = setTimeout(() => {
|
|
724
|
+
void (async () => {
|
|
725
|
+
const reloaded = await peekProgress();
|
|
726
|
+
if (!reloaded)
|
|
727
|
+
return;
|
|
728
|
+
const nonce = reloaded.lastEvent?.nonce ?? null;
|
|
729
|
+
if (nonce === lastSeenNonce)
|
|
730
|
+
return;
|
|
731
|
+
file = reloaded;
|
|
732
|
+
lastSeenNonce = nonce;
|
|
733
|
+
broadcastAll(buildSnapshot(reloaded));
|
|
734
|
+
})();
|
|
735
|
+
}, 150);
|
|
736
|
+
});
|
|
737
|
+
watcher.unref();
|
|
738
|
+
}
|
|
739
|
+
catch (err) {
|
|
740
|
+
log("DEBUG", "app-sidecar", "progress watch unavailable", {
|
|
741
|
+
message: err instanceof Error ? err.message : String(err),
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
return { snapshot, awardRun };
|
|
745
|
+
}
|
|
606
746
|
/**
|
|
607
747
|
* Build one in-process agent session: its AgentSession, SSE client set, event
|
|
608
748
|
* bridge, task runner, auth/login bridge, and the full HTTP route table exposed
|
|
@@ -611,7 +751,7 @@ async function buildNolanContext(buildSession, cwd, gitBranch, question) {
|
|
|
611
751
|
* logger, PATH, shared auth file, and radio live at the daemon level.
|
|
612
752
|
*/
|
|
613
753
|
async function createSession(deps, opts) {
|
|
614
|
-
const { auth } = deps;
|
|
754
|
+
const { auth, progress } = deps;
|
|
615
755
|
const paths = deps.paths;
|
|
616
756
|
const cwd = opts.cwd;
|
|
617
757
|
// Base host for parsing request-URL query params (value is irrelevant to
|
|
@@ -672,6 +812,16 @@ async function createSession(deps, opts) {
|
|
|
672
812
|
...(f.statusCode != null ? { statusCode: f.statusCode } : {}),
|
|
673
813
|
...(f.resetsAt != null ? { resetsAt: f.resetsAt } : {}),
|
|
674
814
|
});
|
|
815
|
+
// Persist the error row (display-only marker) so a resumed session shows
|
|
816
|
+
// the same headline/message/guidance the live run did. Best-effort.
|
|
817
|
+
void session
|
|
818
|
+
.persistAppMarker("error", {
|
|
819
|
+
scope: type,
|
|
820
|
+
headline: f.headline,
|
|
821
|
+
...(f.message ? { message: f.message } : {}),
|
|
822
|
+
guidance: f.guidance,
|
|
823
|
+
})
|
|
824
|
+
.catch(() => { });
|
|
675
825
|
}
|
|
676
826
|
// The session file path to resume (passed by the daemon's POST /session);
|
|
677
827
|
// empty/unset starts a fresh session.
|
|
@@ -694,10 +844,27 @@ async function createSession(deps, opts) {
|
|
|
694
844
|
// session plan state (rebuilds the system prompt + enforces read-only
|
|
695
845
|
// tools) and surface the transition to the webview.
|
|
696
846
|
onEnterPlan: async (reason) => {
|
|
847
|
+
// During a task run there is no human present to approve a plan, and a
|
|
848
|
+
// submitted plan would strand the task in-progress while the run-all loop
|
|
849
|
+
// advances to the next one. Decline plan mode so the agent implements the
|
|
850
|
+
// task directly — mirrors the TUI's isUnattendedRun guard (App.tsx). The
|
|
851
|
+
// enter_plan tool turns a false return into an "implement directly" hint.
|
|
852
|
+
if (taskRunActive)
|
|
853
|
+
return false;
|
|
697
854
|
await session.setPlanMode(true);
|
|
698
855
|
broadcast("plan_enter", { reason: reason ?? "" });
|
|
856
|
+
// Persist the plan-mode banner so a resumed session still shows it.
|
|
857
|
+
void session.persistAppMarker("plan", { reason: reason ?? "" }).catch(() => { });
|
|
699
858
|
},
|
|
700
859
|
onExitPlan: async (planPath) => {
|
|
860
|
+
// A model can call exit_plan even when enter_plan was declined. During a
|
|
861
|
+
// task run, never route the plan into review (no approver) — that would
|
|
862
|
+
// hang the task and stall run-all. Leave plan mode and tell it to build.
|
|
863
|
+
if (taskRunActive) {
|
|
864
|
+
await session.setPlanMode(false);
|
|
865
|
+
return ("Plan review is unavailable during a task run. Skip review and implement " +
|
|
866
|
+
"the plan directly now: make the changes, verify them, then mark the task done.");
|
|
867
|
+
}
|
|
701
868
|
await session.setPlanMode(false);
|
|
702
869
|
// Surface the plan's path + markdown so the webview can show the review
|
|
703
870
|
// modal (Accept / Feedback / Reject). Best-effort content read.
|
|
@@ -708,6 +875,12 @@ async function createSession(deps, opts) {
|
|
|
708
875
|
catch {
|
|
709
876
|
content = "";
|
|
710
877
|
}
|
|
878
|
+
// Record the submitted plan so the autopilot gate can route this turn
|
|
879
|
+
// into a PLAN review instead of a stale work review (plan mode is
|
|
880
|
+
// already false here, so the gate's planMode check alone never catches
|
|
881
|
+
// a submission). setPendingPlan bumps planGeneration, which invalidates
|
|
882
|
+
// any in-flight Nolan plan review racing a user action.
|
|
883
|
+
setPendingPlan(planPath, content);
|
|
711
884
|
broadcast("plan_exit", { planPath, content });
|
|
712
885
|
return "Plan submitted for user review. Wait for the user to approve, reject, or dismiss it before implementing.";
|
|
713
886
|
},
|
|
@@ -780,6 +953,9 @@ async function createSession(deps, opts) {
|
|
|
780
953
|
session.eventBus.on("compaction_end", (d) => broadcast("compaction_end", d));
|
|
781
954
|
let running = false;
|
|
782
955
|
let titleGenerated = false;
|
|
956
|
+
// Bumped by /cancel — a run whose cancel generation changed mid-flight was
|
|
957
|
+
// canceled and earns no XP.
|
|
958
|
+
let cancelGeneration = 0;
|
|
783
959
|
// Autopilot (auto-review) toggle for THIS window's project. Loaded from
|
|
784
960
|
// ezcoder-app.json on boot; flipped via POST /autopilot. When on, POST /prompt runs
|
|
785
961
|
// runAutopilotCycle after the user's turn settles — Nolan auto-reviews the work
|
|
@@ -798,6 +974,45 @@ async function createSession(deps, opts) {
|
|
|
798
974
|
let autopilotCancelled = false;
|
|
799
975
|
// Hard cap on review→prompt→review rounds per user turn (loop safety).
|
|
800
976
|
const MAX_AUTOPILOT_ROUNDS = 3;
|
|
977
|
+
// Prompt bodies Autopilot Nolan injected into the BUILD session this
|
|
978
|
+
// conversation. Passed into every Nolan digest so injected prompts render as
|
|
979
|
+
// "Nolan autopilot (injected)" instead of `**User:**` — otherwise multi-round
|
|
980
|
+
// cycles drift into Nolan reviewing against his own last prompt. Cleared
|
|
981
|
+
// whenever the conversation resets (new session / plan accept / task run).
|
|
982
|
+
let injectedAutopilotPrompts = [];
|
|
983
|
+
// The plan EZ Coder submitted via exit_plan that still awaits a decision
|
|
984
|
+
// (Nolan's auto-review in autopilot, or the user's modal). Path + the content
|
|
985
|
+
// read at submission time (fallback if the file becomes unreadable).
|
|
986
|
+
let pendingPlanPath = null;
|
|
987
|
+
let pendingPlanContent = "";
|
|
988
|
+
// Bumped on EVERY pending-plan set/clear. Nolan's plan review captures it
|
|
989
|
+
// before reviewing and re-checks it before acting on the verdict, so a user
|
|
990
|
+
// Accept/Reject racing an in-flight review always wins — the stale verdict
|
|
991
|
+
// is discarded silently.
|
|
992
|
+
let planGeneration = 0;
|
|
993
|
+
function setPendingPlan(planPath, content) {
|
|
994
|
+
pendingPlanPath = planPath;
|
|
995
|
+
pendingPlanContent = content;
|
|
996
|
+
planGeneration++;
|
|
997
|
+
}
|
|
998
|
+
function clearPendingPlan() {
|
|
999
|
+
if (pendingPlanPath === null)
|
|
1000
|
+
return;
|
|
1001
|
+
pendingPlanPath = null;
|
|
1002
|
+
pendingPlanContent = "";
|
|
1003
|
+
planGeneration++;
|
|
1004
|
+
}
|
|
1005
|
+
// Workflow (prompt-template) commands: built-in + the project's custom
|
|
1006
|
+
// `.ezcoder/commands/*.md`. Used to gate autopilot off command turns and to label
|
|
1007
|
+
// expanded templates in Nolan's digests. Loaded fresh so a newly added custom
|
|
1008
|
+
// command is picked up without a restart (mirrors GET /commands).
|
|
1009
|
+
async function loadWorkflowCommandSpecs() {
|
|
1010
|
+
const custom = await loadCustomCommands(cwd).catch(() => []);
|
|
1011
|
+
return [
|
|
1012
|
+
...PROMPT_COMMANDS.map((c) => ({ name: c.name, aliases: c.aliases, prompt: c.prompt })),
|
|
1013
|
+
...custom.map((c) => ({ name: c.name, aliases: [], prompt: c.prompt })),
|
|
1014
|
+
];
|
|
1015
|
+
}
|
|
801
1016
|
// ── Telegram serve (remote control via Telegram) ───────────
|
|
802
1017
|
// A single embedded serve session lives in this sidecar process. Only the main
|
|
803
1018
|
// window's home screen exposes the controls, so there's one bot per app.
|
|
@@ -813,6 +1028,35 @@ async function createSession(deps, opts) {
|
|
|
813
1028
|
let nolanRunning = false;
|
|
814
1029
|
let pendingNolanModel = null;
|
|
815
1030
|
const nolanToolCallNames = new Map();
|
|
1031
|
+
// Nolan's per-project model override. null → Nolan (chat + autopilot) follows GG
|
|
1032
|
+
// Coder's model, including live switches (the historical behavior). Set → Nolan
|
|
1033
|
+
// is pinned to his own model and EZ Coder switches no longer touch him. A
|
|
1034
|
+
// stale persisted pin (model dropped from the registry / provider logged
|
|
1035
|
+
// out) validates to null so Nolan degrades to following instead of erroring.
|
|
1036
|
+
let nolanModelOverride = validateNolanModelPref(await loadNolanModelPref(cwd), {
|
|
1037
|
+
modelExists: (id) => getModel(id) !== undefined,
|
|
1038
|
+
providerConnected: () => true, // async auth checked below
|
|
1039
|
+
});
|
|
1040
|
+
if (nolanModelOverride && !(await auth.hasProviderAuth(nolanModelOverride.provider))) {
|
|
1041
|
+
log("WARN", "app-sidecar", "ken model override provider not connected — following GG", {
|
|
1042
|
+
provider: nolanModelOverride.provider,
|
|
1043
|
+
model: nolanModelOverride.model,
|
|
1044
|
+
});
|
|
1045
|
+
nolanModelOverride = null;
|
|
1046
|
+
}
|
|
1047
|
+
/** The model Nolan uses next turn: the pin when set, else EZ Coder's. */
|
|
1048
|
+
function nolanCurrentModel() {
|
|
1049
|
+
if (nolanModelOverride)
|
|
1050
|
+
return nolanModelOverride;
|
|
1051
|
+
const st = session.getState();
|
|
1052
|
+
return { provider: st.provider, model: st.model };
|
|
1053
|
+
}
|
|
1054
|
+
/** Footer payload: Nolan's effective model + whether it's a pin. Merged into
|
|
1055
|
+
* /state, the SSE ready frame, and every ken_model_change broadcast. */
|
|
1056
|
+
function nolanStatePayload() {
|
|
1057
|
+
const st = session.getState();
|
|
1058
|
+
return effectiveNolanModel(nolanModelOverride, { provider: st.provider, model: st.model });
|
|
1059
|
+
}
|
|
816
1060
|
async function syncNolanModel(provider, model) {
|
|
817
1061
|
if (nolanRunning) {
|
|
818
1062
|
pendingNolanModel = { provider, model };
|
|
@@ -829,16 +1073,19 @@ async function createSession(deps, opts) {
|
|
|
829
1073
|
async function ensureNolanSession() {
|
|
830
1074
|
if (nolanSession)
|
|
831
1075
|
return nolanSession;
|
|
832
|
-
const
|
|
1076
|
+
const target = nolanCurrentModel();
|
|
833
1077
|
const ken = new AgentSession({
|
|
834
|
-
provider:
|
|
835
|
-
model:
|
|
1078
|
+
provider: target.provider,
|
|
1079
|
+
model: target.model,
|
|
836
1080
|
cwd,
|
|
837
|
-
systemPrompt: buildNolanSystemPrompt(),
|
|
1081
|
+
systemPrompt: await buildNolanSystemPrompt(cwd),
|
|
838
1082
|
allowedTools: NOLAN_ALLOWED_TOOLS,
|
|
839
1083
|
allowedMcpServers: NOLAN_ALLOWED_MCP_SERVERS,
|
|
840
1084
|
transient: true,
|
|
841
1085
|
signal: nolanAbort.signal,
|
|
1086
|
+
// Nolan's bursty, spread-out turns (chat) outlast the default 5-min cache
|
|
1087
|
+
// TTL regardless of the user's global speedProfile pick.
|
|
1088
|
+
forceLongCacheRetention: true,
|
|
842
1089
|
});
|
|
843
1090
|
await ken.initialize();
|
|
844
1091
|
// Bridge Nolan's bus to the shared SSE fan-out with ken_-prefixed types so the
|
|
@@ -863,7 +1110,10 @@ async function createSession(deps, opts) {
|
|
|
863
1110
|
broadcastError("nolan_error", "ken error", d.error);
|
|
864
1111
|
});
|
|
865
1112
|
nolanSession = ken;
|
|
866
|
-
log("INFO", "app-sidecar", "ken session ready", {
|
|
1113
|
+
log("INFO", "app-sidecar", "ken session ready", {
|
|
1114
|
+
provider: target.provider,
|
|
1115
|
+
model: target.model,
|
|
1116
|
+
});
|
|
867
1117
|
return ken;
|
|
868
1118
|
}
|
|
869
1119
|
// ── Autopilot Nolan (auto-reviewer) ──────────────────────────
|
|
@@ -892,24 +1142,27 @@ async function createSession(deps, opts) {
|
|
|
892
1142
|
async function ensureNolanAutoSession() {
|
|
893
1143
|
if (nolanAutoSession)
|
|
894
1144
|
return nolanAutoSession;
|
|
895
|
-
const
|
|
1145
|
+
const target = nolanCurrentModel();
|
|
896
1146
|
const ken = new AgentSession({
|
|
897
|
-
provider:
|
|
898
|
-
model:
|
|
1147
|
+
provider: target.provider,
|
|
1148
|
+
model: target.model,
|
|
899
1149
|
cwd,
|
|
900
|
-
systemPrompt: buildNolanAutopilotSystemPrompt(),
|
|
1150
|
+
systemPrompt: await buildNolanAutopilotSystemPrompt(cwd),
|
|
901
1151
|
allowedTools: NOLAN_ALLOWED_TOOLS,
|
|
902
1152
|
allowedMcpServers: NOLAN_ALLOWED_MCP_SERVERS,
|
|
903
1153
|
transient: true,
|
|
904
1154
|
signal: nolanAutoAbort.signal,
|
|
1155
|
+
// Autopilot review rounds routinely span the injected EZ Coder run
|
|
1156
|
+
// (often >5 min) regardless of the user's global speedProfile pick.
|
|
1157
|
+
forceLongCacheRetention: true,
|
|
905
1158
|
});
|
|
906
1159
|
await ken.initialize();
|
|
907
1160
|
// Deliberately no bus bridge: the review is silent. Errors surface via the
|
|
908
1161
|
// runAutopilotReview try/catch as autopilot_error frames.
|
|
909
1162
|
nolanAutoSession = ken;
|
|
910
1163
|
log("INFO", "app-sidecar", "ken autopilot session ready", {
|
|
911
|
-
provider:
|
|
912
|
-
model:
|
|
1164
|
+
provider: target.provider,
|
|
1165
|
+
model: target.model,
|
|
913
1166
|
});
|
|
914
1167
|
return ken;
|
|
915
1168
|
}
|
|
@@ -933,15 +1186,28 @@ async function createSession(deps, opts) {
|
|
|
933
1186
|
// run_start frame.
|
|
934
1187
|
async function runAgent(label, run) {
|
|
935
1188
|
running = true;
|
|
1189
|
+
// Progress (Ranks): completed, non-canceled runs with ≥1 assistant turn earn
|
|
1190
|
+
// XP — prompt + any commits authored during the run window.
|
|
1191
|
+
const runStartedAt = Date.now();
|
|
1192
|
+
const cancelGenAtStart = cancelGeneration;
|
|
1193
|
+
const assistantsBeforeRun = countAssistantMessages(session.getMessages());
|
|
1194
|
+
let runSucceeded = false;
|
|
936
1195
|
broadcast("run_start", { text: label });
|
|
937
1196
|
try {
|
|
938
1197
|
await run();
|
|
1198
|
+
runSucceeded = true;
|
|
939
1199
|
}
|
|
940
1200
|
catch (err) {
|
|
941
1201
|
broadcastError("error", "run failed", err);
|
|
942
1202
|
}
|
|
943
1203
|
finally {
|
|
944
1204
|
running = false;
|
|
1205
|
+
if (runSucceeded &&
|
|
1206
|
+
cancelGeneration === cancelGenAtStart &&
|
|
1207
|
+
countAssistantMessages(session.getMessages()) > assistantsBeforeRun) {
|
|
1208
|
+
// Fire-and-forget — XP must never delay or break run teardown.
|
|
1209
|
+
void progress.awardRun(cwd, runStartedAt, opts.id);
|
|
1210
|
+
}
|
|
945
1211
|
// A run may have switched branches (git checkout) or spawned/finished
|
|
946
1212
|
// background tasks — refresh the footer extras once it settles.
|
|
947
1213
|
gitBranch = await getGitBranch(cwd).catch(() => gitBranch);
|
|
@@ -974,17 +1240,20 @@ async function createSession(deps, opts) {
|
|
|
974
1240
|
// One review = prompt the nolanAuto session with the review digest, read its
|
|
975
1241
|
// final assistant text, parse a verdict. Returns null on failure (surfaced as
|
|
976
1242
|
// an autopilot_error frame) so the cycle stops rather than looping blind.
|
|
977
|
-
|
|
1243
|
+
// `originalRequest` is the user prompt that started the turn under review —
|
|
1244
|
+
// pinned in the digest so it can't scroll out during multi-round cycles.
|
|
1245
|
+
async function runAutopilotReview(originalRequest) {
|
|
978
1246
|
autopilotReviewing = true;
|
|
979
1247
|
broadcast("autopilot_review_start", {});
|
|
980
1248
|
try {
|
|
981
1249
|
const ken = await ensureNolanAutoSession();
|
|
982
|
-
const projectContext = await collectProjectContext(cwd).catch(() => []);
|
|
983
1250
|
const digest = buildNolanAutopilotContext({
|
|
984
|
-
projectContext,
|
|
985
1251
|
cwd,
|
|
986
1252
|
gitBranch,
|
|
987
1253
|
messages: session.getMessages(),
|
|
1254
|
+
originalRequest,
|
|
1255
|
+
injectedPrompts: [...injectedAutopilotPrompts],
|
|
1256
|
+
workflowCommands: await loadWorkflowCommandSpecs(),
|
|
988
1257
|
});
|
|
989
1258
|
await ken.prompt(digest);
|
|
990
1259
|
return parseAutopilotVerdict(lastAssistantText(ken.getMessages()));
|
|
@@ -1002,45 +1271,254 @@ async function createSession(deps, opts) {
|
|
|
1002
1271
|
await syncNolanAutoModel(pending.provider, pending.model);
|
|
1003
1272
|
}
|
|
1004
1273
|
}
|
|
1274
|
+
// One PLAN review: like runAutopilotReview but the digest carries the
|
|
1275
|
+
// submitted plan's markdown (`## Plan under review`) and the plan-review
|
|
1276
|
+
// instruction — Nolan judges the plan itself, not finished work. Returns null
|
|
1277
|
+
// on failure; a failure caused by the user's own action racing the review
|
|
1278
|
+
// (cancel or a manual Accept/Reject that bumped planGeneration) stays
|
|
1279
|
+
// SILENT — no autopilot_error — because the user's decision already won.
|
|
1280
|
+
async function runAutopilotPlanReview(originalRequest) {
|
|
1281
|
+
const planPath = pendingPlanPath;
|
|
1282
|
+
if (planPath === null)
|
|
1283
|
+
return null;
|
|
1284
|
+
const genAtStart = planGeneration;
|
|
1285
|
+
autopilotReviewing = true;
|
|
1286
|
+
broadcast("autopilot_review_start", {});
|
|
1287
|
+
try {
|
|
1288
|
+
const ken = await ensureNolanAutoSession();
|
|
1289
|
+
// Re-read the plan file (the run may have revised it in place); fall
|
|
1290
|
+
// back to the content captured at exit_plan time.
|
|
1291
|
+
const planContent = await fs.readFile(planPath, "utf-8").catch(() => pendingPlanContent);
|
|
1292
|
+
const digest = buildNolanAutopilotPlanContext({
|
|
1293
|
+
cwd,
|
|
1294
|
+
gitBranch,
|
|
1295
|
+
messages: session.getMessages(),
|
|
1296
|
+
originalRequest,
|
|
1297
|
+
injectedPrompts: [...injectedAutopilotPrompts],
|
|
1298
|
+
workflowCommands: await loadWorkflowCommandSpecs(),
|
|
1299
|
+
planContent,
|
|
1300
|
+
});
|
|
1301
|
+
await ken.prompt(digest);
|
|
1302
|
+
if (autopilotCancelled || planGeneration !== genAtStart)
|
|
1303
|
+
return null;
|
|
1304
|
+
return parseAutopilotVerdict(lastAssistantText(ken.getMessages()));
|
|
1305
|
+
}
|
|
1306
|
+
catch (err) {
|
|
1307
|
+
// User action mid-review (manual Accept aborts the nolanAuto run): drop
|
|
1308
|
+
// the review silently — the user's decision supersedes Nolan's.
|
|
1309
|
+
if (autopilotCancelled || planGeneration !== genAtStart)
|
|
1310
|
+
return null;
|
|
1311
|
+
broadcastError("autopilot_error", "autopilot plan review failed", err);
|
|
1312
|
+
return null;
|
|
1313
|
+
}
|
|
1314
|
+
finally {
|
|
1315
|
+
autopilotReviewing = false;
|
|
1316
|
+
// Apply any model switch that landed mid-review.
|
|
1317
|
+
const pending = pendingNolanAutoModel;
|
|
1318
|
+
pendingNolanAutoModel = null;
|
|
1319
|
+
if (pending)
|
|
1320
|
+
await syncNolanAutoModel(pending.provider, pending.model);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
// The prompt fed to the fresh session after a plan is accepted — the SAME
|
|
1324
|
+
// string the webview sends on a manual Accept (see PlanReviewModal's accept
|
|
1325
|
+
// handler in ezcoder-app/src/App.tsx). Keep the two in lockstep so auto- and
|
|
1326
|
+
// manual approval produce identical implementation turns.
|
|
1327
|
+
const IMPLEMENT_PLAN_PROMPT = "The plan has been approved. Implement it now, following each step in order.";
|
|
1005
1328
|
// Drive the review→prompt→review loop for one finished user turn. Only ever
|
|
1006
|
-
// called
|
|
1007
|
-
// task runner, resume, /ken, or
|
|
1008
|
-
//
|
|
1009
|
-
|
|
1329
|
+
// called after shouldStartAutopilotCycle approves the turn (POST /prompt or
|
|
1330
|
+
// the stranded-queue drain) — never from the task runner, resume, /ken, or
|
|
1331
|
+
// error paths, so there's no recursion and no guard tangle. The loop's
|
|
1332
|
+
// control flow lives in driveAutopilotCycle (core/autopilot-cycle.ts) so
|
|
1333
|
+
// every exit path is unit-tested; this only wires the real dependencies.
|
|
1334
|
+
async function runAutopilotCycle(originalRequest) {
|
|
1010
1335
|
if (!autopilot || autopilotCancelled)
|
|
1011
1336
|
return;
|
|
1012
1337
|
autopilotActive = true;
|
|
1338
|
+
// Generation captured by the last plan review; acceptPlan re-checks it so
|
|
1339
|
+
// a user Accept/Reject landing mid-review always wins.
|
|
1340
|
+
let planGenAtReview = -1;
|
|
1013
1341
|
try {
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1342
|
+
await driveAutopilotCycle({
|
|
1343
|
+
// A plan-pending cycle needs extra rounds: approve+implement and the
|
|
1344
|
+
// post-implement work review each consume one, so +2 keeps a real fix
|
|
1345
|
+
// round available.
|
|
1346
|
+
maxRounds: pendingPlanPath !== null ? MAX_AUTOPILOT_ROUNDS + 2 : MAX_AUTOPILOT_ROUNDS,
|
|
1347
|
+
isCancelled: () => autopilotCancelled,
|
|
1348
|
+
// An injected run entering plan mode WITHOUT submitting (enter_plan,
|
|
1349
|
+
// no exit_plan) halts the cycle — Nolan never prompts into a read-only
|
|
1350
|
+
// plan-mode session. A submitted plan takes the planPending branch.
|
|
1351
|
+
isPlanMode: () => session.getPlanMode(),
|
|
1352
|
+
planPending: () => pendingPlanPath !== null,
|
|
1353
|
+
reviewPlan: async () => {
|
|
1354
|
+
planGenAtReview = planGeneration;
|
|
1355
|
+
return runAutopilotPlanReview(originalRequest);
|
|
1356
|
+
},
|
|
1357
|
+
// Auto-accept: the inlined POST /plan/accept body. Returns false when
|
|
1358
|
+
// the plan generation moved since the review (user acted) — the cycle
|
|
1359
|
+
// exits silently and the user's action stands.
|
|
1360
|
+
acceptPlan: async () => {
|
|
1361
|
+
if (pendingPlanPath === null || planGeneration !== planGenAtReview)
|
|
1362
|
+
return false;
|
|
1363
|
+
const planPath = pendingPlanPath;
|
|
1364
|
+
try {
|
|
1365
|
+
await session.newSession();
|
|
1366
|
+
injectedAutopilotPrompts = [];
|
|
1367
|
+
titleGenerated = false;
|
|
1368
|
+
await session.setApprovedPlan(planPath);
|
|
1369
|
+
}
|
|
1370
|
+
catch (err) {
|
|
1371
|
+
broadcastError("autopilot_error", "autopilot plan accept failed", err);
|
|
1372
|
+
return false;
|
|
1373
|
+
}
|
|
1374
|
+
clearPendingPlan();
|
|
1375
|
+
// Ordering is load-bearing: the webview reads its still-open plan
|
|
1376
|
+
// modal state (step count) on autopilot_plan_accepted, and
|
|
1377
|
+
// session_reset clears it — accepted must land first.
|
|
1378
|
+
broadcast("autopilot_plan_accepted", {});
|
|
1379
|
+
broadcast("session_reset", {});
|
|
1380
|
+
// Persisted into the NEW session so a resume shows the marker.
|
|
1381
|
+
void session.persistAutopilotMarker("plan_approved");
|
|
1382
|
+
return true;
|
|
1383
|
+
},
|
|
1384
|
+
runImplement: () => {
|
|
1385
|
+
// Autopilot-injected run: frame it so EZ Coder knows no human is
|
|
1386
|
+
// watching the implementation. Record the framed string so Nolan's
|
|
1387
|
+
// digest labels it as injected, not as the user's ask. The run_start
|
|
1388
|
+
// label stays the clean prompt.
|
|
1389
|
+
const framed = frameAutopilotInjection(IMPLEMENT_PLAN_PROMPT);
|
|
1390
|
+
injectedAutopilotPrompts.push(framed);
|
|
1391
|
+
return runAgent(IMPLEMENT_PLAN_PROMPT, () => session.prompt(framed));
|
|
1392
|
+
},
|
|
1393
|
+
// Lean context per user turn: wipe prior review history so each new
|
|
1394
|
+
// turn starts cheap, while within this cycle the few review messages
|
|
1395
|
+
// persist so Nolan remembers what he already asked EZ Coder to fix.
|
|
1396
|
+
resetReviewer: async () => {
|
|
1397
|
+
await nolanAutoSession?.newSession().catch(() => { });
|
|
1398
|
+
},
|
|
1399
|
+
review: () => runAutopilotReview(originalRequest),
|
|
1400
|
+
// prompt → record the injected body (so later digests label it as
|
|
1401
|
+
// Nolan's, not the user's), show a compact Nolan-tinted marker (not the
|
|
1402
|
+
// prompt body), then feed EZ Coder bracketed by runAgent so the run
|
|
1403
|
+
// streams normally; the shared finally never re-triggers autopilot,
|
|
1404
|
+
// so this can't recurse.
|
|
1405
|
+
onInjected: (body, round) => {
|
|
1406
|
+
// A revision injection supersedes the pending plan — if the run
|
|
1407
|
+
// resubmits via exit_plan, onExitPlan re-sets it (no-op for work-
|
|
1408
|
+
// branch injections, where nothing is pending).
|
|
1409
|
+
clearPendingPlan();
|
|
1410
|
+
// Record the FRAMED string (what actually lands in the build session,
|
|
1411
|
+
// see runPrompt) so Nolan's digest matches and labels it as injected.
|
|
1412
|
+
// The webview marker + persisted body stay the CLEAN prompt so the UI
|
|
1413
|
+
// shows Nolan's actual instruction, not the autopilot preamble.
|
|
1414
|
+
injectedAutopilotPrompts.push(frameAutopilotInjection(body));
|
|
1415
|
+
broadcast("autopilot_prompted", { round, body });
|
|
1416
|
+
void session.persistAutopilotMarker("prompted", { body });
|
|
1417
|
+
},
|
|
1418
|
+
// Autopilot-injected run: EZ Coder receives the framed prompt (no human
|
|
1419
|
+
// is watching this turn) while run_start keeps the clean label.
|
|
1420
|
+
runPrompt: (body) => runAgent(body, () => session.prompt(frameAutopilotInjection(body))),
|
|
1421
|
+
emit: (event) => {
|
|
1422
|
+
// Persist the terminal verdict marker so a resumed session renders the
|
|
1423
|
+
// same Nolan bubble the live run showed instead of dropping it or
|
|
1424
|
+
// falling back to the raw verdict text (e.g. ALL_CLEAR).
|
|
1425
|
+
if (event.type === "autopilot_done") {
|
|
1426
|
+
// Broadcast the SAME copySeed the persisted marker will produce on
|
|
1427
|
+
// resume, so the live all-clear wording matches the resumed one
|
|
1428
|
+
// (computed before persist — same synchronous message count).
|
|
1429
|
+
const seed = autopilotMarkerCopySeed({
|
|
1430
|
+
version: 1,
|
|
1431
|
+
phase: "done",
|
|
1432
|
+
afterMessageCount: session.getMessages().filter((m) => m.role !== "system").length,
|
|
1433
|
+
});
|
|
1434
|
+
broadcast(event.type, { ...event.data, copySeed: seed });
|
|
1435
|
+
void session.persistAutopilotMarker("done");
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
broadcast(event.type, event.data);
|
|
1439
|
+
if (event.type === "autopilot_human") {
|
|
1440
|
+
void session.persistAutopilotMarker("human", { reason: event.data.reason });
|
|
1441
|
+
}
|
|
1442
|
+
else if (event.type === "autopilot_capped") {
|
|
1443
|
+
void session.persistAutopilotMarker("capped");
|
|
1444
|
+
}
|
|
1445
|
+
// autopilot_ignored renders nothing live, so nothing is persisted either.
|
|
1446
|
+
},
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
finally {
|
|
1450
|
+
autopilotActive = false;
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
// ── Stranded-queue drain ───────────────────────────────
|
|
1454
|
+
// A prompt POSTed while an autopilot cycle is between injected runs (build
|
|
1455
|
+
// idle, Nolan reviewing) queues — but the queue only drains INTO a running
|
|
1456
|
+
// turn as steering. If the cycle ends without another run (ALL_CLEAR /
|
|
1457
|
+
// IGNORE / HUMAN / error), that message would sit stranded until the next
|
|
1458
|
+
// unrelated prompt, then land mislabeled as "concurrent steering" of an
|
|
1459
|
+
// unrelated run. Drain it here as a fresh turn of its own (with its own
|
|
1460
|
+
// gated review). Also covers the non-autopilot tail window: a message queued
|
|
1461
|
+
// after the run's last steering drain but before run_end.
|
|
1462
|
+
let drainingStrandedQueue = false;
|
|
1463
|
+
async function runStrandedQueue() {
|
|
1464
|
+
if (drainingStrandedQueue)
|
|
1465
|
+
return;
|
|
1466
|
+
drainingStrandedQueue = true;
|
|
1467
|
+
try {
|
|
1468
|
+
for (;;) {
|
|
1469
|
+
if (running || autopilotActive)
|
|
1470
|
+
return;
|
|
1471
|
+
const next = session.takeNextQueuedMessage();
|
|
1472
|
+
if (!next)
|
|
1473
|
+
return;
|
|
1474
|
+
broadcast("queued", { count: session.getQueuedCount() });
|
|
1475
|
+
if (!next.text.trim() && next.attachments.length === 0)
|
|
1476
|
+
continue;
|
|
1477
|
+
// A queued message draining as a fresh turn supersedes any pending
|
|
1478
|
+
// plan, exactly like a direct POST /prompt turn.
|
|
1479
|
+
clearPendingPlan();
|
|
1480
|
+
const workflowCommand = next.attachments.length === 0 &&
|
|
1481
|
+
isWorkflowCommandText(next.text, await loadWorkflowCommandSpecs());
|
|
1482
|
+
const assistantsBefore = countAssistantMessages(session.getMessages());
|
|
1483
|
+
const messagesBefore = session.getMessages().length;
|
|
1484
|
+
await runAgent(next.text, async () => {
|
|
1485
|
+
if (next.attachments.length > 0) {
|
|
1486
|
+
await session.promptWithAttachments(next.text, next.attachments);
|
|
1487
|
+
}
|
|
1488
|
+
else {
|
|
1489
|
+
await session.prompt(next.text);
|
|
1490
|
+
}
|
|
1491
|
+
});
|
|
1492
|
+
const decision = shouldStartAutopilotCycle({
|
|
1493
|
+
enabled: autopilot,
|
|
1494
|
+
cancelled: autopilotCancelled,
|
|
1495
|
+
planMode: session.getPlanMode(),
|
|
1496
|
+
// A submitted plan (exit_plan fired) routes into the PLAN review
|
|
1497
|
+
// branch — the cycle reviews the plan itself instead of skipping.
|
|
1498
|
+
planPending: pendingPlanPath !== null,
|
|
1499
|
+
workflowCommand,
|
|
1500
|
+
assistantMessagesAdded: countAssistantMessages(session.getMessages()) - assistantsBefore,
|
|
1501
|
+
// Skip the review API call outright for turns that only started a
|
|
1502
|
+
// background process (dev server/watcher), ran a read-only lookup, or
|
|
1503
|
+
// committed/pushed — Nolan's autopilot contract already IGNOREs these,
|
|
1504
|
+
// so there's no reason to pay for that verdict.
|
|
1505
|
+
mechanicalOnly: isMechanicalOnlyTurn(extractTurnToolCalls(session.getMessages(), messagesBefore)),
|
|
1506
|
+
});
|
|
1507
|
+
if (decision.start) {
|
|
1508
|
+
log("INFO", "app-sidecar", "autopilot cycle starting (queued turn)", {
|
|
1509
|
+
kind: decision.kind,
|
|
1510
|
+
});
|
|
1511
|
+
await runAutopilotCycle(next.text);
|
|
1027
1512
|
}
|
|
1028
|
-
if (
|
|
1029
|
-
|
|
1030
|
-
|
|
1513
|
+
else if (autopilot) {
|
|
1514
|
+
log("INFO", "app-sidecar", "autopilot skipped (queued turn)", {
|
|
1515
|
+
reason: decision.reason,
|
|
1516
|
+
});
|
|
1031
1517
|
}
|
|
1032
|
-
// prompt → show a compact Nolan-tinted marker (not the prompt body), then
|
|
1033
|
-
// feed EZ Coder. Bracketed by runAgent so the run streams normally; the
|
|
1034
|
-
// shared finally no longer re-triggers autopilot, so this can't recurse.
|
|
1035
|
-
broadcast("autopilot_prompted", { round, body: verdict.body });
|
|
1036
|
-
await runAgent(verdict.body, () => session.prompt(verdict.body));
|
|
1037
|
-
if (autopilotCancelled)
|
|
1038
|
-
return;
|
|
1039
1518
|
}
|
|
1040
|
-
broadcast("autopilot_capped", { rounds: MAX_AUTOPILOT_ROUNDS });
|
|
1041
1519
|
}
|
|
1042
1520
|
finally {
|
|
1043
|
-
|
|
1521
|
+
drainingStrandedQueue = false;
|
|
1044
1522
|
}
|
|
1045
1523
|
}
|
|
1046
1524
|
// ── Task runner (project task list → sessions) ──────────────
|
|
@@ -1048,17 +1526,34 @@ async function createSession(deps, opts) {
|
|
|
1048
1526
|
// completion hint instructing the agent to mark the task done via the tasks
|
|
1049
1527
|
// tool. Run-all advances to the next pending task after each run finishes.
|
|
1050
1528
|
let taskRunAll = false;
|
|
1529
|
+
// True for the duration of any task run (single or run-all). The plan-mode
|
|
1530
|
+
// callbacks read this to decline plan mode when no human is present to
|
|
1531
|
+
// approve a plan, so tasks implement directly instead of hanging on review.
|
|
1532
|
+
let taskRunActive = false;
|
|
1051
1533
|
async function runTaskById(taskId) {
|
|
1052
1534
|
const task = loadTasksSync(cwd).find((t) => t.id === taskId || t.id.startsWith(taskId));
|
|
1053
1535
|
if (!task)
|
|
1054
1536
|
return false;
|
|
1537
|
+
taskRunActive = true;
|
|
1538
|
+
try {
|
|
1539
|
+
return await runTaskByIdInner(task);
|
|
1540
|
+
}
|
|
1541
|
+
finally {
|
|
1542
|
+
taskRunActive = false;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
async function runTaskByIdInner(task) {
|
|
1055
1546
|
// Fresh session per task so one task's context never bleeds into the next.
|
|
1056
1547
|
await session.newSession();
|
|
1548
|
+
injectedAutopilotPrompts = [];
|
|
1549
|
+
clearPendingPlan();
|
|
1057
1550
|
titleGenerated = false;
|
|
1058
1551
|
broadcast("session_reset", {});
|
|
1059
1552
|
markTaskInProgress(cwd, task.id);
|
|
1060
1553
|
broadcast("tasks_list", { tasks: loadTasksSync(cwd) });
|
|
1061
1554
|
broadcast("task_start", { id: task.id, title: task.title });
|
|
1555
|
+
// Persist the task header so a resumed task session shows what ran.
|
|
1556
|
+
void session.persistAppMarker("task", { title: task.title }).catch(() => { });
|
|
1062
1557
|
const shortId = task.id.slice(0, 8);
|
|
1063
1558
|
const completionHint = `\n\n---\nWhen you have fully completed this task, call the tasks tool to mark it done:\n` +
|
|
1064
1559
|
`tasks({ action: "done", id: "${shortId}" })`;
|
|
@@ -1164,10 +1659,15 @@ async function createSession(deps, opts) {
|
|
|
1164
1659
|
supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
|
|
1165
1660
|
supportsVideo: getModel(st.model)?.supportsVideo ?? false,
|
|
1166
1661
|
autopilot,
|
|
1662
|
+
...nolanStatePayload(),
|
|
1167
1663
|
...footerExtras(),
|
|
1168
1664
|
});
|
|
1169
1665
|
return;
|
|
1170
1666
|
}
|
|
1667
|
+
if (method === "GET" && url === "/progress") {
|
|
1668
|
+
json(res, 200, progress.snapshot());
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1171
1671
|
if (method === "GET" && (url === "/events" || url.startsWith("/events?"))) {
|
|
1172
1672
|
res.writeHead(200, {
|
|
1173
1673
|
"content-type": "text/event-stream",
|
|
@@ -1188,6 +1688,7 @@ async function createSession(deps, opts) {
|
|
|
1188
1688
|
supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
|
|
1189
1689
|
supportsVideo: getModel(st.model)?.supportsVideo ?? false,
|
|
1190
1690
|
autopilot,
|
|
1691
|
+
...nolanStatePayload(),
|
|
1191
1692
|
...footerExtras(),
|
|
1192
1693
|
},
|
|
1193
1694
|
})}\n\n`);
|
|
@@ -1346,8 +1847,11 @@ async function createSession(deps, opts) {
|
|
|
1346
1847
|
// they were recorded after, so each lands right after that message. A
|
|
1347
1848
|
// turn becomes two wire rows: the `@Nolan` question (user) + Nolan's reply
|
|
1348
1849
|
// (assistant), both flagged `ken` so the webview tints them.
|
|
1850
|
+
// Deduped; stale anchors are clamped to the last message (Nolan turns
|
|
1851
|
+
// carry real conversation, so they render at the end instead of
|
|
1852
|
+
// vanishing).
|
|
1349
1853
|
const nolanByCount = new Map();
|
|
1350
|
-
for (const turn of session.getNolanTurns()) {
|
|
1854
|
+
for (const turn of normalizeNolanTurnsForHistory(session.getNolanTurns(), messages.filter((m) => m.role !== "system").length)) {
|
|
1351
1855
|
const list = nolanByCount.get(turn.afterMessageCount) ?? [];
|
|
1352
1856
|
list.push(turn);
|
|
1353
1857
|
nolanByCount.set(turn.afterMessageCount, list);
|
|
@@ -1362,9 +1866,103 @@ async function createSession(deps, opts) {
|
|
|
1362
1866
|
history.push({ role: "assistant", text: turn.reply, ken: true });
|
|
1363
1867
|
}
|
|
1364
1868
|
};
|
|
1869
|
+
// Autopilot verdict markers to interleave, same anchor scheme as Nolan
|
|
1870
|
+
// turns — each becomes a single assistant row the webview renders
|
|
1871
|
+
// exactly like the live `autopilot` item (never a raw verdict string).
|
|
1872
|
+
// Compact/continuation rewrites can carry old markers whose original
|
|
1873
|
+
// afterMessageCount is beyond the restored message list; dropping those
|
|
1874
|
+
// prevents stale all-clear bubbles from bunching at the bottom on resume.
|
|
1875
|
+
const restoredMessageCount = messages.filter((m) => m.role !== "system").length;
|
|
1876
|
+
const autopilotByCount = new Map();
|
|
1877
|
+
for (const marker of normalizeAutopilotMarkersForHistory(session.getAutopilotMarkers(), restoredMessageCount)) {
|
|
1878
|
+
const list = autopilotByCount.get(marker.afterMessageCount) ?? [];
|
|
1879
|
+
list.push(marker);
|
|
1880
|
+
autopilotByCount.set(marker.afterMessageCount, list);
|
|
1881
|
+
}
|
|
1882
|
+
const flushAutopilot = (count) => {
|
|
1883
|
+
const markers = autopilotByCount.get(count);
|
|
1884
|
+
if (!markers)
|
|
1885
|
+
return;
|
|
1886
|
+
autopilotByCount.delete(count);
|
|
1887
|
+
for (const marker of markers) {
|
|
1888
|
+
history.push({
|
|
1889
|
+
role: "assistant",
|
|
1890
|
+
text: "",
|
|
1891
|
+
autopilot: {
|
|
1892
|
+
phase: marker.phase,
|
|
1893
|
+
...(marker.reason !== undefined ? { reason: marker.reason } : {}),
|
|
1894
|
+
...(marker.body !== undefined ? { body: marker.body } : {}),
|
|
1895
|
+
copySeed: marker.copySeed,
|
|
1896
|
+
},
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
};
|
|
1900
|
+
// App transcript markers (plan banner / task header / error rows /
|
|
1901
|
+
// user-bubble hints), same anchor scheme. user_hint markers don't
|
|
1902
|
+
// become rows — they decorate the user row at their anchor instead.
|
|
1903
|
+
const appMarkersByCount = new Map();
|
|
1904
|
+
const userHintByCount = new Map();
|
|
1905
|
+
// Compaction-count markers pair with compacted summary rows in file
|
|
1906
|
+
// order (FIFO), not by anchor — the summary user message is what
|
|
1907
|
+
// positions the notice.
|
|
1908
|
+
const compactionCounts = [];
|
|
1909
|
+
for (const marker of normalizeAppMarkersForHistory(session.getAppMarkers(), restoredMessageCount)) {
|
|
1910
|
+
if (marker.kind === "user_hint") {
|
|
1911
|
+
userHintByCount.set(marker.afterMessageCount, marker.data);
|
|
1912
|
+
continue;
|
|
1913
|
+
}
|
|
1914
|
+
if (marker.kind === "compaction") {
|
|
1915
|
+
const d = marker.data;
|
|
1916
|
+
if (typeof d.originalCount === "number" && typeof d.newCount === "number") {
|
|
1917
|
+
compactionCounts.push({ originalCount: d.originalCount, newCount: d.newCount });
|
|
1918
|
+
}
|
|
1919
|
+
continue;
|
|
1920
|
+
}
|
|
1921
|
+
const list = appMarkersByCount.get(marker.afterMessageCount) ?? [];
|
|
1922
|
+
list.push(marker);
|
|
1923
|
+
appMarkersByCount.set(marker.afterMessageCount, list);
|
|
1924
|
+
}
|
|
1925
|
+
const flushAppMarkers = (count) => {
|
|
1926
|
+
const markers = appMarkersByCount.get(count);
|
|
1927
|
+
if (!markers)
|
|
1928
|
+
return;
|
|
1929
|
+
appMarkersByCount.delete(count);
|
|
1930
|
+
for (const marker of markers) {
|
|
1931
|
+
const d = marker.data;
|
|
1932
|
+
if (marker.kind === "plan") {
|
|
1933
|
+
history.push({
|
|
1934
|
+
role: "assistant",
|
|
1935
|
+
text: "",
|
|
1936
|
+
plan: { reason: typeof d.reason === "string" ? d.reason : "" },
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
else if (marker.kind === "task") {
|
|
1940
|
+
history.push({
|
|
1941
|
+
role: "assistant",
|
|
1942
|
+
text: "",
|
|
1943
|
+
task: { title: typeof d.title === "string" ? d.title : "" },
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
else if (marker.kind === "error" && typeof d.headline === "string") {
|
|
1947
|
+
history.push({
|
|
1948
|
+
role: "assistant",
|
|
1949
|
+
text: "",
|
|
1950
|
+
error: {
|
|
1951
|
+
scope: typeof d.scope === "string" ? d.scope : "error",
|
|
1952
|
+
headline: d.headline,
|
|
1953
|
+
...(typeof d.message === "string" ? { message: d.message } : {}),
|
|
1954
|
+
...(typeof d.guidance === "string" ? { guidance: d.guidance } : {}),
|
|
1955
|
+
},
|
|
1956
|
+
});
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
};
|
|
1365
1960
|
let nonSystemCount = 0;
|
|
1366
|
-
// Turns recorded before any build message (anchor 0) render at
|
|
1961
|
+
// Turns/markers recorded before any build message (anchor 0) render at
|
|
1962
|
+
// the top.
|
|
1367
1963
|
flushNolan(0);
|
|
1964
|
+
flushAutopilot(0);
|
|
1965
|
+
flushAppMarkers(0);
|
|
1368
1966
|
for (const msg of messages) {
|
|
1369
1967
|
if (msg.role === "system")
|
|
1370
1968
|
continue;
|
|
@@ -1414,30 +2012,54 @@ async function createSession(deps, opts) {
|
|
|
1414
2012
|
}
|
|
1415
2013
|
continue;
|
|
1416
2014
|
}
|
|
1417
|
-
// User or assistant message —
|
|
1418
|
-
//
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
2015
|
+
// User or assistant message — text/hook/command/compacted extraction,
|
|
2016
|
+
// plus sub-agent group detection for assistant tool_calls.
|
|
2017
|
+
if (msg.role === "user") {
|
|
2018
|
+
// Rebuild the live bubble: strip the steering wrapper, drop
|
|
2019
|
+
// attachment/file notes the model saw but the bubble never showed.
|
|
2020
|
+
const restored = restoreUserRow(msg.content);
|
|
2021
|
+
const text = restored.text;
|
|
2022
|
+
const hook = detectHookKind(text);
|
|
2023
|
+
const compacted = !hook && text.startsWith("[Previous conversation summary]");
|
|
2024
|
+
const command = !hook && !compacted ? detectPromptCommand(text, commandCandidates) : null;
|
|
2025
|
+
if (text.trim() || restored.images.length > 0) {
|
|
2026
|
+
const hint = userHintByCount.get(nonSystemCount);
|
|
2027
|
+
history.push({
|
|
2028
|
+
role: "user",
|
|
2029
|
+
text: command ?? text,
|
|
2030
|
+
images: restored.images,
|
|
2031
|
+
hook,
|
|
2032
|
+
command: command !== null,
|
|
2033
|
+
compacted,
|
|
2034
|
+
// Markers accumulate across continuation files (each rewrite
|
|
2035
|
+
// re-persists prior ones) but only the LATEST summary row
|
|
2036
|
+
// survives compaction — so consume from the newest end.
|
|
2037
|
+
...(compacted && compactionCounts.length > 0
|
|
2038
|
+
? { compactionCounts: compactionCounts.pop() }
|
|
2039
|
+
: {}),
|
|
2040
|
+
...(hint?.nolanSent === true ? { nolanSent: true } : {}),
|
|
2041
|
+
...(Array.isArray(hint?.enhancements) ? { enhancements: hint.enhancements } : {}),
|
|
2042
|
+
});
|
|
2043
|
+
// Live showed the video-capability warning right after the bubble.
|
|
2044
|
+
if (restored.videoWarning) {
|
|
2045
|
+
history.push({ role: "assistant", text: "", infoKind: "video_warning" });
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
else {
|
|
2050
|
+
// Assistant: one wire row per persisted text block — live streaming
|
|
2051
|
+
// splits bubbles at server_tool_call boundaries, and the persisted
|
|
2052
|
+
// content keeps those blocks separate.
|
|
2053
|
+
for (const blockText of restoreAssistantTexts(msg.content)) {
|
|
2054
|
+
history.push({
|
|
2055
|
+
role: "assistant",
|
|
2056
|
+
text: blockText,
|
|
2057
|
+
images: [],
|
|
2058
|
+
hook: null,
|
|
2059
|
+
command: false,
|
|
2060
|
+
compacted: false,
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
1441
2063
|
}
|
|
1442
2064
|
// Assistant tool_call blocks: detect sub-agent delegations.
|
|
1443
2065
|
if (msg.role === "assistant" && typeof msg.content !== "string") {
|
|
@@ -1458,14 +2080,21 @@ async function createSession(deps, opts) {
|
|
|
1458
2080
|
});
|
|
1459
2081
|
}
|
|
1460
2082
|
}
|
|
1461
|
-
// Interleave any Nolan turns
|
|
2083
|
+
// Interleave any Nolan turns / autopilot / app markers recorded right
|
|
2084
|
+
// after this message.
|
|
1462
2085
|
flushNolan(nonSystemCount);
|
|
2086
|
+
flushAutopilot(nonSystemCount);
|
|
2087
|
+
flushAppMarkers(nonSystemCount);
|
|
1463
2088
|
}
|
|
1464
|
-
// Flush remaining Nolan turns whose anchor is at/after the message count
|
|
1465
|
-
//
|
|
1466
|
-
// count
|
|
2089
|
+
// Flush remaining Nolan turns whose anchor is at/after the message count so
|
|
2090
|
+
// none are dropped. Autopilot/app markers beyond the restored message
|
|
2091
|
+
// count were already filtered above; any remaining marker here is valid.
|
|
1467
2092
|
for (const count of [...nolanByCount.keys()].sort((a, b) => a - b))
|
|
1468
2093
|
flushNolan(count);
|
|
2094
|
+
for (const count of [...autopilotByCount.keys()].sort((a, b) => a - b))
|
|
2095
|
+
flushAutopilot(count);
|
|
2096
|
+
for (const count of [...appMarkersByCount.keys()].sort((a, b) => a - b))
|
|
2097
|
+
flushAppMarkers(count);
|
|
1469
2098
|
json(res, 200, { history });
|
|
1470
2099
|
})();
|
|
1471
2100
|
return;
|
|
@@ -1498,10 +2127,12 @@ async function createSession(deps, opts) {
|
|
|
1498
2127
|
void readBody(req).then(async (raw) => {
|
|
1499
2128
|
let text;
|
|
1500
2129
|
let attachments;
|
|
2130
|
+
let meta;
|
|
1501
2131
|
try {
|
|
1502
2132
|
const body = JSON.parse(raw);
|
|
1503
2133
|
text = body.text ?? "";
|
|
1504
2134
|
attachments = Array.isArray(body.attachments) ? body.attachments : [];
|
|
2135
|
+
meta = typeof body.meta === "object" && body.meta !== null ? body.meta : undefined;
|
|
1505
2136
|
}
|
|
1506
2137
|
catch {
|
|
1507
2138
|
json(res, 400, { error: "invalid JSON body" });
|
|
@@ -1525,9 +2156,33 @@ async function createSession(deps, opts) {
|
|
|
1525
2156
|
return;
|
|
1526
2157
|
}
|
|
1527
2158
|
json(res, 202, { accepted: true });
|
|
2159
|
+
// Webview display hint for this prompt's user bubble (nolanSent shimmer
|
|
2160
|
+
// label / enhancer highlight segments). Anchored +1 so it attaches to
|
|
2161
|
+
// the user message the prompt below is about to push. Queued prompts
|
|
2162
|
+
// skip this (their position in the run is unpredictable).
|
|
2163
|
+
if (meta && (meta.nolanSent === true || Array.isArray(meta.enhancements))) {
|
|
2164
|
+
void session
|
|
2165
|
+
.persistAppMarker("user_hint", {
|
|
2166
|
+
...(meta.nolanSent === true ? { nolanSent: true } : {}),
|
|
2167
|
+
...(Array.isArray(meta.enhancements) ? { enhancements: meta.enhancements } : {}),
|
|
2168
|
+
}, 1)
|
|
2169
|
+
.catch(() => { });
|
|
2170
|
+
}
|
|
1528
2171
|
// Fresh user turn: clear any cancel flag left from a prior cycle so this
|
|
1529
2172
|
// turn's autopilot review can run.
|
|
1530
2173
|
autopilotCancelled = false;
|
|
2174
|
+
// A typed message while a plan modal/review is pending (reject,
|
|
2175
|
+
// feedback, anything) supersedes the pending plan — the bump also
|
|
2176
|
+
// invalidates any in-flight Nolan plan review.
|
|
2177
|
+
clearPendingPlan();
|
|
2178
|
+
// Gate inputs captured around the run: whether this turn is a workflow
|
|
2179
|
+
// slash command (attachment prompts skip slash expansion entirely), and
|
|
2180
|
+
// how many assistant messages the run actually adds. Computed even when
|
|
2181
|
+
// autopilot is currently off — the toggle can flip ON mid-run, and the
|
|
2182
|
+
// gate reads the post-run value.
|
|
2183
|
+
const workflowCommand = attachments.length === 0 && isWorkflowCommandText(text, await loadWorkflowCommandSpecs());
|
|
2184
|
+
const assistantsBefore = countAssistantMessages(session.getMessages());
|
|
2185
|
+
const messagesBefore = session.getMessages().length;
|
|
1531
2186
|
await runAgent(text, async () => {
|
|
1532
2187
|
if (attachments.length > 0) {
|
|
1533
2188
|
// Persist each attachment under .ezcoder/uploads so files are inspectable
|
|
@@ -1543,11 +2198,40 @@ async function createSession(deps, opts) {
|
|
|
1543
2198
|
await session.prompt(text);
|
|
1544
2199
|
}
|
|
1545
2200
|
});
|
|
1546
|
-
// After the user's run settles, kick off Nolan's auto-review loop
|
|
1547
|
-
//
|
|
1548
|
-
//
|
|
1549
|
-
|
|
1550
|
-
|
|
2201
|
+
// After the user's run settles, kick off Nolan's auto-review loop — but
|
|
2202
|
+
// only when the turn is actually reviewable (shouldStartAutopilotCycle):
|
|
2203
|
+
// workflow commands (/compare, /bullet-proof, …) end with reports or
|
|
2204
|
+
// A/B/C choices reserved for the USER; registry commands (/help) and
|
|
2205
|
+
// failed runs add no assistant work to judge; a turn that ended in plan
|
|
2206
|
+
// mode has a pending Accept/Reject modal Nolan must not preempt. This is
|
|
2207
|
+
// the ONLY entry point into the cycle besides the stranded-queue drain —
|
|
2208
|
+
// it drives any follow-up EZ Coder runs itself, so the shared runAgent
|
|
2209
|
+
// finally never recurses.
|
|
2210
|
+
const decision = shouldStartAutopilotCycle({
|
|
2211
|
+
enabled: autopilot,
|
|
2212
|
+
cancelled: autopilotCancelled,
|
|
2213
|
+
planMode: session.getPlanMode(),
|
|
2214
|
+
// A submitted plan (exit_plan fired) routes into the PLAN review
|
|
2215
|
+
// branch — the cycle reviews the plan itself instead of skipping.
|
|
2216
|
+
planPending: pendingPlanPath !== null,
|
|
2217
|
+
workflowCommand,
|
|
2218
|
+
assistantMessagesAdded: countAssistantMessages(session.getMessages()) - assistantsBefore,
|
|
2219
|
+
// Skip the review API call outright for turns that only started a
|
|
2220
|
+
// background process (dev server/watcher), ran a read-only lookup, or
|
|
2221
|
+
// committed/pushed — Nolan's autopilot contract already IGNOREs these,
|
|
2222
|
+
// so there's no reason to pay for that verdict.
|
|
2223
|
+
mechanicalOnly: isMechanicalOnlyTurn(extractTurnToolCalls(session.getMessages(), messagesBefore)),
|
|
2224
|
+
});
|
|
2225
|
+
if (decision.start) {
|
|
2226
|
+
log("INFO", "app-sidecar", "autopilot cycle starting", { kind: decision.kind });
|
|
2227
|
+
await runAutopilotCycle(text);
|
|
2228
|
+
}
|
|
2229
|
+
else if (autopilot) {
|
|
2230
|
+
log("INFO", "app-sidecar", "autopilot skipped", { reason: decision.reason });
|
|
2231
|
+
}
|
|
2232
|
+
// A prompt sent while Nolan was reviewing (build idle) queued but had no
|
|
2233
|
+
// run to steer into — run it now as a fresh turn so it never strands.
|
|
2234
|
+
await runStrandedQueue();
|
|
1551
2235
|
});
|
|
1552
2236
|
return;
|
|
1553
2237
|
}
|
|
@@ -1578,7 +2262,7 @@ async function createSession(deps, opts) {
|
|
|
1578
2262
|
broadcast("nolan_run_start", { text });
|
|
1579
2263
|
try {
|
|
1580
2264
|
const ken = await ensureNolanSession();
|
|
1581
|
-
const digest = await buildNolanContext(session, cwd, gitBranch, text);
|
|
2265
|
+
const digest = await buildNolanContext(session, cwd, gitBranch, text, await loadWorkflowCommandSpecs(), injectedAutopilotPrompts);
|
|
1582
2266
|
await ken.prompt(digest);
|
|
1583
2267
|
// Record the turn against the BUILD session so it persists + survives
|
|
1584
2268
|
// resume (advisory custom entry, never an LLM message). Reply is Nolan's
|
|
@@ -1861,8 +2545,12 @@ async function createSession(deps, opts) {
|
|
|
1861
2545
|
// new model's credentials, so switching to a just-added provider works.
|
|
1862
2546
|
await auth.reload();
|
|
1863
2547
|
await session.switchModel(target.provider, target.id);
|
|
1864
|
-
|
|
1865
|
-
|
|
2548
|
+
// Nolan follows EZ Coder's model only while un-pinned; a user-set Nolan
|
|
2549
|
+
// override survives GG model switches untouched.
|
|
2550
|
+
if (!nolanModelOverride) {
|
|
2551
|
+
await syncNolanModel(target.provider, target.id);
|
|
2552
|
+
await syncNolanAutoModel(target.provider, target.id);
|
|
2553
|
+
}
|
|
1866
2554
|
// Clamp the reasoning level to what the new model supports (mirrors the
|
|
1867
2555
|
// CLI): keep thinking on at the first supported tier if it was on but
|
|
1868
2556
|
// the prior level is unsupported here; leave it off if it was off.
|
|
@@ -1889,6 +2577,12 @@ async function createSession(deps, opts) {
|
|
|
1889
2577
|
// model_change is emitted by switchModel; follow with thinking_change so
|
|
1890
2578
|
// the footer toggle reflects the new model's supported levels.
|
|
1891
2579
|
broadcast("thinking_change", payload);
|
|
2580
|
+
// Un-pinned Nolan just followed the switch — update his footer chip too.
|
|
2581
|
+
// When Nolan is pinned, his effective model did not change, so skip the
|
|
2582
|
+
// no-op event (keeps footer/event tests from treating a GG switch as a
|
|
2583
|
+
// Nolan switch).
|
|
2584
|
+
if (!nolanModelOverride)
|
|
2585
|
+
broadcast("ken_model_change", nolanStatePayload());
|
|
1892
2586
|
// The new model usually has a different context window — push extras so
|
|
1893
2587
|
// the footer's context meter rescales immediately.
|
|
1894
2588
|
broadcast("extras", footerExtras());
|
|
@@ -1896,6 +2590,54 @@ async function createSession(deps, opts) {
|
|
|
1896
2590
|
});
|
|
1897
2591
|
return;
|
|
1898
2592
|
}
|
|
2593
|
+
// Set or clear Nolan's model pin. Body: { model: "<id>" } to pin, or
|
|
2594
|
+
// { model: null } / "" to clear (Nolan resumes following EZ Coder). Applies
|
|
2595
|
+
// to BOTH Nolan sessions (chat + autopilot reviewer); a switch landing while
|
|
2596
|
+
// either is mid-run defers via the pending-model mechanics.
|
|
2597
|
+
if (method === "POST" && url === "/ken/model") {
|
|
2598
|
+
void readBody(req).then(async (raw) => {
|
|
2599
|
+
let modelId;
|
|
2600
|
+
try {
|
|
2601
|
+
const parsed = JSON.parse(raw).model;
|
|
2602
|
+
modelId = typeof parsed === "string" && parsed.trim() ? parsed.trim() : null;
|
|
2603
|
+
}
|
|
2604
|
+
catch {
|
|
2605
|
+
json(res, 400, { error: "invalid JSON body" });
|
|
2606
|
+
return;
|
|
2607
|
+
}
|
|
2608
|
+
if (modelId === null) {
|
|
2609
|
+
// Clear the pin → follow EZ Coder again, syncing both sessions back.
|
|
2610
|
+
nolanModelOverride = null;
|
|
2611
|
+
await saveNolanModelPref(cwd, null);
|
|
2612
|
+
const st = session.getState();
|
|
2613
|
+
await syncNolanModel(st.provider, st.model);
|
|
2614
|
+
await syncNolanAutoModel(st.provider, st.model);
|
|
2615
|
+
log("INFO", "app-sidecar", "ken model pin cleared — following GG", {
|
|
2616
|
+
provider: st.provider,
|
|
2617
|
+
model: st.model,
|
|
2618
|
+
});
|
|
2619
|
+
}
|
|
2620
|
+
else {
|
|
2621
|
+
const target = getModel(modelId);
|
|
2622
|
+
if (!target) {
|
|
2623
|
+
json(res, 404, { error: `unknown model: ${modelId}` });
|
|
2624
|
+
return;
|
|
2625
|
+
}
|
|
2626
|
+
nolanModelOverride = { provider: target.provider, model: target.id };
|
|
2627
|
+
await saveNolanModelPref(cwd, nolanModelOverride);
|
|
2628
|
+
await syncNolanModel(target.provider, target.id);
|
|
2629
|
+
await syncNolanAutoModel(target.provider, target.id);
|
|
2630
|
+
log("INFO", "app-sidecar", "ken model pinned", {
|
|
2631
|
+
provider: target.provider,
|
|
2632
|
+
model: target.id,
|
|
2633
|
+
});
|
|
2634
|
+
}
|
|
2635
|
+
const payload = nolanStatePayload();
|
|
2636
|
+
broadcast("ken_model_change", payload);
|
|
2637
|
+
json(res, 200, payload);
|
|
2638
|
+
});
|
|
2639
|
+
return;
|
|
2640
|
+
}
|
|
1899
2641
|
if (method === "POST" && url === "/kill") {
|
|
1900
2642
|
void readBody(req).then(async (raw) => {
|
|
1901
2643
|
let id;
|
|
@@ -1938,12 +2680,14 @@ async function createSession(deps, opts) {
|
|
|
1938
2680
|
return;
|
|
1939
2681
|
}
|
|
1940
2682
|
if (method === "POST" && url === "/cancel") {
|
|
2683
|
+
cancelGeneration++;
|
|
1941
2684
|
abort.abort();
|
|
1942
2685
|
abort = new AbortController();
|
|
1943
2686
|
session.setSignal(abort.signal);
|
|
1944
2687
|
running = false;
|
|
1945
2688
|
// Stop a run-all sweep so the next pending task isn't auto-started.
|
|
1946
2689
|
taskRunAll = false;
|
|
2690
|
+
taskRunActive = false;
|
|
1947
2691
|
// Stop any in-flight autopilot cycle: flag it so the loop bails between
|
|
1948
2692
|
// steps, and abort a review that's mid-prompt on the nolanAuto session.
|
|
1949
2693
|
autopilotCancelled = true;
|
|
@@ -1967,6 +2711,8 @@ async function createSession(deps, opts) {
|
|
|
1967
2711
|
void session
|
|
1968
2712
|
.newSession()
|
|
1969
2713
|
.then(() => {
|
|
2714
|
+
injectedAutopilotPrompts = [];
|
|
2715
|
+
clearPendingPlan();
|
|
1970
2716
|
broadcast("session_reset", {});
|
|
1971
2717
|
json(res, 200, { ok: true });
|
|
1972
2718
|
})
|
|
@@ -1998,8 +2744,27 @@ async function createSession(deps, opts) {
|
|
|
1998
2744
|
json(res, 409, { error: "cannot accept a plan while the agent is running" });
|
|
1999
2745
|
return;
|
|
2000
2746
|
}
|
|
2747
|
+
// Manual accept, possibly racing Nolan's autopilot plan review: the user
|
|
2748
|
+
// always wins. Bump the plan generation (invalidates any in-flight
|
|
2749
|
+
// review's verdict), stop the cycle, abort a mid-prompt review on the
|
|
2750
|
+
// nolanAuto session, and clear the spinner — autopilot_ignored renders
|
|
2751
|
+
// nothing, so no stale "approve or reject" bubble ever lands. The
|
|
2752
|
+
// webview's follow-up "implement" /prompt arrives as a fresh turn
|
|
2753
|
+
// (resetting autopilotCancelled), so the implementation still gets its
|
|
2754
|
+
// normal post-run review; if it lands while the cycle is winding down
|
|
2755
|
+
// it queues and runStrandedQueue drains it as a fresh turn.
|
|
2756
|
+
clearPendingPlan();
|
|
2757
|
+
autopilotCancelled = true;
|
|
2758
|
+
nolanAutoAbort.abort();
|
|
2759
|
+
nolanAutoAbort = new AbortController();
|
|
2760
|
+
nolanAutoSession?.setSignal(nolanAutoAbort.signal);
|
|
2761
|
+
if (autopilotReviewing) {
|
|
2762
|
+
autopilotReviewing = false;
|
|
2763
|
+
broadcast("autopilot_ignored", {});
|
|
2764
|
+
}
|
|
2001
2765
|
try {
|
|
2002
2766
|
await session.newSession();
|
|
2767
|
+
injectedAutopilotPrompts = [];
|
|
2003
2768
|
titleGenerated = false;
|
|
2004
2769
|
await session.setApprovedPlan(planPath);
|
|
2005
2770
|
broadcast("session_reset", {});
|