From eda601c46acb0d5c82e1fc943e4c4dc8d7ef43fe Mon Sep 17 00:00:00 2001 From: Artem Khoroshilov Date: Fri, 30 Jan 2026 04:37:44 +0200 Subject: [PATCH 1/2] Doctor update: skip restore control-ui when untracked --- src/infra/update-runner.test.ts | 36 ++++++++++++++++++++ src/infra/update-runner.ts | 60 +++++++++++++++++++++++++++++---- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 6a49a85c0..97ab434ff 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -112,6 +112,9 @@ describe("runGatewayUpdate", () => { "pnpm install": { stdout: "" }, "pnpm build": { stdout: "" }, "pnpm ui:build": { stdout: "" }, + [`git -C ${tempDir} ls-files -- dist/control-ui/`]: { + stdout: "dist/control-ui/index.html\n", + }, [`git -C ${tempDir} checkout -- dist/control-ui/`]: { stdout: "" }, "pnpm moltbot doctor --non-interactive": { stdout: "" }, }); @@ -128,6 +131,39 @@ describe("runGatewayUpdate", () => { expect(calls).not.toContain(`git -C ${tempDir} checkout --detach ${betaTag}`); }); + it("skips restoring control-ui when dist/control-ui is not tracked", async () => { + await fs.mkdir(path.join(tempDir, ".git")); + await fs.writeFile( + path.join(tempDir, "package.json"), + JSON.stringify({ name: "moltbot", version: "1.0.0", packageManager: "pnpm@8.0.0" }), + "utf-8", + ); + const stableTag = "v1.0.1-1"; + const { runner, calls } = createRunner({ + [`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir }, + [`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" }, + [`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" }, + [`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" }, + [`git -C ${tempDir} tag --list v* --sort=-v:refname`]: { stdout: `${stableTag}\n` }, + [`git -C ${tempDir} checkout --detach ${stableTag}`]: { stdout: "" }, + "pnpm install": { stdout: "" }, + "pnpm build": { stdout: "" }, + "pnpm ui:build": { stdout: "" }, + [`git -C ${tempDir} ls-files -- dist/control-ui/`]: { stdout: "" }, + "pnpm moltbot doctor --non-interactive": { stdout: "" }, + }); + + const result = await runGatewayUpdate({ + cwd: tempDir, + runCommand: async (argv, _options) => runner(argv), + timeoutMs: 5000, + channel: "beta", + }); + + expect(result.status).toBe("ok"); + expect(calls).not.toContain(`git -C ${tempDir} checkout -- dist/control-ui/`); + }); + it("skips update when no git root", async () => { await fs.writeFile( path.join(tempDir, "package.json"), diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 0735edb39..8f9ff746c 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -275,6 +275,41 @@ async function runStep(opts: RunStepOptions): Promise { }; } +function skipStep( + opts: Pick & { + stdoutTail?: string | null; + stderrTail?: string | null; + }, +): UpdateStepResult { + const { name, argv, cwd, progress, stepIndex, totalSteps, stdoutTail, stderrTail } = opts; + const command = argv.join(" "); + + const stepInfo: UpdateStepInfo = { + name, + command, + index: stepIndex, + total: totalSteps, + }; + + progress?.onStepStart?.(stepInfo); + progress?.onStepComplete?.({ + ...stepInfo, + durationMs: 0, + exitCode: 0, + stderrTail: stderrTail ?? null, + }); + + return { + name, + command, + cwd, + durationMs: 0, + exitCode: 0, + stdoutTail: stdoutTail ?? null, + stderrTail: stderrTail ?? null, + }; +} + function managerScriptArgs(manager: "pnpm" | "bun" | "npm", script: string, args: string[] = []) { if (manager === "pnpm") return ["pnpm", script, ...args]; if (manager === "bun") return ["bun", "run", script, ...args]; @@ -678,13 +713,26 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< // Restore dist/control-ui/ to committed state to prevent dirty repo after update // (ui:build regenerates assets with new hashes, which would block future updates) - const restoreUiStep = await runStep( - step( - "restore control-ui", - ["git", "-C", gitRoot, "checkout", "--", "dist/control-ui/"], - gitRoot, - ), + const restoreUiOpts = step( + "restore control-ui", + ["git", "-C", gitRoot, "checkout", "--", "dist/control-ui/"], + gitRoot, ); + const lsFilesResult = await runCommand( + ["git", "-C", gitRoot, "ls-files", "--", "dist/control-ui/"], + { + cwd: gitRoot, + timeoutMs, + }, + ); + const hasTrackedControlUiFiles = + lsFilesResult.code === 0 && lsFilesResult.stdout.trim().length > 0; + const restoreUiStep = hasTrackedControlUiFiles + ? await runStep(restoreUiOpts) + : skipStep({ + ...restoreUiOpts, + stdoutTail: "Skipped: dist/control-ui/ is not tracked in git.", + }); steps.push(restoreUiStep); const doctorStep = await runStep( From 902e3f56d11488336ecb6f1cf325a3d28e976a7c Mon Sep 17 00:00:00 2001 From: Artem Khoroshilov Date: Fri, 30 Jan 2026 04:59:25 +0200 Subject: [PATCH 2/2] fix(agents): initialize ExtensionRunner in embedded mode (#2027) In pi-coding-agent, ExtensionRunner defaults ctx.model to undefined until extensionRunner.initialize() is called. Moltbot uses the SDK in embedded mode (no interactive UI), so compaction hooks that rely on ctx.model (e.g. compaction-safeguard) would fall back to "Summary unavailable..." and history would be truncated without a real summary. --- CHANGELOG.md | 1 + src/agents/pi-embedded-runner/run/attempt.ts | 75 +++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a134359f5..a0598df2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Status: beta. - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). ### Fixes +- Agents: initialize ExtensionRunner in embedded mode so compaction hooks produce real summaries instead of "Summary unavailable...". (#2027) - Security: harden SSH tunnel target parsing to prevent option injection/DoS. (#4001) Thanks @YLChen-007. - Security: prevent PATH injection in exec sandbox; harden file serving; pin DNS in URL fetches; verify Twilio webhooks; fix LINE webhook timing-attack edge case; validate Tailscale Serve identity; flag loopback Control UI with auth disabled as critical. (#1616, #1795) - Gateway: prevent crashes on transient network errors, suppress AbortError/unhandled rejections, sanitize error responses, clean session locks on exit, and harden reverse proxy handling for unauthenticated proxied connects. (#2980, #2451, #2483, #1795) diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 46a53bd8f..a5d2a2f6f 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -4,7 +4,12 @@ import os from "node:os"; import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, ImageContent } from "@mariozechner/pi-ai"; import { streamSimple } from "@mariozechner/pi-ai"; -import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent"; +import { + createAgentSession, + SessionManager, + SettingsManager, + type CompactOptions, +} from "@mariozechner/pi-coding-agent"; import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js"; import { @@ -492,6 +497,74 @@ export async function runEmbeddedAttempt( // Force a stable streamFn reference so vitest can reliably mock @mariozechner/pi-ai. activeSession.agent.streamFn = streamSimple; + // IMPORTANT (embedded mode): initialize the pi extension runner. + // + // In pi-coding-agent, ExtensionRunner defaults `ctx.model` to `undefined` until + // `extensionRunner.initialize(...)` is called. Moltbot uses the SDK in embedded + // mode (no interactive UI), so without this initialization compaction hooks that + // rely on `ctx.model` (e.g. `compaction-safeguard`) will always fall back to + // "Summary unavailable..." and history will be truncated without a summary. + const extensionRunner = activeSession.extensionRunner; + if (extensionRunner) { + extensionRunner.initialize( + { + // Embedded runs don't have a UI; keep UI-dependent actions as safe no-ops. + sendMessage: async () => {}, + sendUserMessage: async () => {}, + appendEntry: () => {}, + setSessionName: () => {}, + getSessionName: () => undefined, + setLabel: () => {}, + getActiveTools: () => + (activeSession.agent.state.tools ?? []) + .map((tool) => { + if (!tool) return undefined; + if (typeof tool === "string") return tool; + if (typeof tool === "object" && "name" in tool) { + const name = (tool as { name?: unknown }).name; + return typeof name === "string" ? name : undefined; + } + return undefined; + }) + .filter((name): name is string => typeof name === "string"), + getAllTools: () => [], + setActiveTools: () => {}, + setModel: async (model) => { + const key = await activeSession.modelRegistry.getApiKey(model); + if (!key) return false; + await activeSession.setModel(model); + return true; + }, + getThinkingLevel: () => activeSession.thinkingLevel, + setThinkingLevel: (level) => { + activeSession.setThinkingLevel(level); + }, + }, + { + // Provide live model + context access for extensions. + getModel: () => activeSession.agent.state.model, + isIdle: () => true, + abort: () => { + void activeSession.abort(); + }, + hasPendingMessages: () => false, + shutdown: () => {}, + getContextUsage: () => activeSession.getContextUsage(), + compact: (options?: CompactOptions) => { + void (async () => { + try { + const result = await activeSession.compact(options?.customInstructions); + options?.onComplete?.(result); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + options?.onError?.(err); + } + })(); + }, + }, + ); + } + applyExtraParamsToAgent( activeSession.agent, params.config,