From 19fed71f093a4633faec3e245b3d603500f28d0e Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 9 Feb 2026 15:08:04 +0800 Subject: [PATCH 1/5] fix(heartbeat): bypass empty-file check for cron-triggered wakes Cron reminders were silently skipped when heartbeat.md had no actionable content. Now cron: and exec-event reasons both bypass the empty-file guard so scheduled reminders always reach the agent. Co-Authored-By: Claude Opus 4.6 --- src/heartbeat/runner.test.ts | 12 ++++++++++++ src/heartbeat/runner.ts | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/heartbeat/runner.test.ts b/src/heartbeat/runner.test.ts index 8a3570a3d6..1a04bc5ff2 100644 --- a/src/heartbeat/runner.test.ts +++ b/src/heartbeat/runner.test.ts @@ -65,6 +65,18 @@ describe("heartbeat runner", () => { } }); + it("bypasses empty-heartbeat-file check for cron-triggered wakes", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "heartbeat-test-")); + try { + await writeFile(path.join(dir, "heartbeat.md"), "# keep empty\n", "utf-8"); + const agent = createStubAgent({ profileDir: dir, replyText: "HEARTBEAT_OK" }); + const result = await runHeartbeatOnce({ agent: agent as any, reason: "cron:test-job-id" }); + expect(result.status).toBe("ran"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + it("runs and returns ran for heartbeat acknowledgements", async () => { const agent = createStubAgent({ replyText: "HEARTBEAT_OK" }); const result = await runHeartbeatOnce({ agent: agent as any, reason: "manual" }); diff --git a/src/heartbeat/runner.ts b/src/heartbeat/runner.ts index f9ba6bfa1d..7e6dab5368 100644 --- a/src/heartbeat/runner.ts +++ b/src/heartbeat/runner.ts @@ -156,8 +156,8 @@ export async function runHeartbeatOnce(opts: { } try { - const isExecEvent = opts.reason === "exec-event"; - if (!isExecEvent && (await isHeartbeatFileEmpty(agent))) { + const isForcedWake = opts.reason === "exec-event" || opts.reason?.startsWith("cron:"); + if (!isForcedWake && (await isHeartbeatFileEmpty(agent))) { emitHeartbeatEvent({ status: "skipped", reason: "empty-heartbeat-file", From 5c7d9131282b141b7a484712d554fd5bf355a7f6 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 9 Feb 2026 15:08:11 +0800 Subject: [PATCH 2/5] fix(desktop): externalize grammy in Vite Electron build grammy was not in rollupOptions.external, causing the channels IPC handlers to fail at runtime with 'No handler registered'. Co-Authored-By: Claude Opus 4.6 --- apps/desktop/vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 61300b85b5..4fe0672645 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -38,6 +38,7 @@ export default defineConfig({ '@mariozechner/pi-agent-core', '@mariozechner/pi-ai', '@mariozechner/pi-coding-agent', + 'grammy', ], }, }, From 15f6604a16fe688dcee08dbfab87c246a4f13675 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 9 Feb 2026 15:41:56 +0800 Subject: [PATCH 3/5] fix(agent): persist provider selection across restarts Move session metadata loading before provider resolution so that the stored provider from a previous setProvider() call is used in the fallback chain (options > session meta > credentials > default) instead of always falling back to "kimi-coding". Co-Authored-By: Claude Opus 4.6 --- src/agent/runner.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 8b98f2dc74..e7ec45eb06 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -111,8 +111,15 @@ export class Agent { this.reasoningMode = options.reasoningMode ?? "stream"; this.output = createAgentOutput({ stdout, stderr: this.stderr, reasoningMode: this.reasoningMode }); - // Resolve provider and model from options > env vars > defaults - const defaultProvider = options.provider ?? credentialManager.getLlmProvider() ?? "kimi-coding"; + // Load session metadata early so stored provider/model can inform defaults + this.sessionId = options.sessionId ?? uuidv7(); + const storedMeta = (() => { + const tempSession = new SessionManager({ sessionId: this.sessionId }); + return tempSession.getMeta(); + })(); + + // Resolve provider and model from options > session meta > env vars > defaults + const defaultProvider = options.provider ?? storedMeta?.provider ?? credentialManager.getLlmProvider() ?? "kimi-coding"; if (options.authProfileId) { const profileProvider = options.authProfileId.includes(":") ? options.authProfileId.split(":")[0]! @@ -194,16 +201,7 @@ export class Agent { }); } - this.sessionId = options.sessionId ?? uuidv7(); - - // 解析 model(用于获取 context window) - const storedMeta = (() => { - // 临时创建 session 获取 meta,避免循环依赖 - const tempSession = new SessionManager({ sessionId: this.sessionId }); - return tempSession.getMeta(); - })(); - - const effectiveProvider = resolvedModel ? this.resolvedProvider : (options.provider ?? storedMeta?.provider); + const effectiveProvider = this.resolvedProvider; const effectiveModel = resolvedModel ?? options.model ?? storedMeta?.model; let model = resolveModel({ ...options, provider: effectiveProvider, model: effectiveModel }); From 2fa8c383fb694c218fa6bcbd2f4aab14dcbd348a Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 9 Feb 2026 15:45:11 +0800 Subject: [PATCH 4/5] fix(agent): save original provider ID instead of alias in session meta setProvider() was saving the alias-resolved provider (e.g. "anthropic" instead of "claude-code") to session metadata. On restart, this caused the wrong provider to be selected. Now saves the original providerId so the exact user selection is preserved. Co-Authored-By: Claude Opus 4.6 --- src/agent/runner.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/agent/runner.ts b/src/agent/runner.ts index e7ec45eb06..afb5914449 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -316,7 +316,7 @@ export class Agent { } this.session.saveMeta({ - provider: this.agent.state.model?.provider, + provider: this.resolvedProvider, model: this.agent.state.model?.id, thinkingLevel: this.agent.state.thinkingLevel, reasoningMode: this.reasoningMode, @@ -874,9 +874,9 @@ export class Agent { // Update internal state this.resolvedProvider = providerId; - // Update session metadata + // Update session metadata (save original providerId, not alias-resolved) this.session.saveMeta({ - provider: actualProvider, + provider: providerId, model: model.id, thinkingLevel: this.agent.state.thinkingLevel, reasoningMode: this.reasoningMode, From 7294e76929e2cc18ff15c8610158d1550b0571ce Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 9 Feb 2026 15:51:32 +0800 Subject: [PATCH 5/5] fix(agent): only emit agent_error for auth issues, not runtime 400 errors Previously all agent errors (including 400 invalid_request_error) were emitted as agent_error events, triggering the UI error banner and interrupting the chat flow. Now only auth-related errors (401, no API key) emit agent_error so the "Configure" banner appears. Runtime errors like 400 are still shown as chat messages but don't block the agent. Co-Authored-By: Claude Opus 4.6 --- src/agent/async-agent.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/agent/async-agent.ts b/src/agent/async-agent.ts index 8015b95eb9..7d322d14ee 100644 --- a/src/agent/async-agent.ts +++ b/src/agent/async-agent.ts @@ -73,15 +73,25 @@ export class AsyncAgent { if (result.error) { console.error(`[AsyncAgent] Agent run error: ${result.error}`); this.channel.send({ id: uuidv7(), content: `[error] ${result.error}` }); - this.agent.emitError(result.error); + // Only emit agent_error for HTTP 401 from the LLM provider so the + // UI shows the "Configure" banner. All other errors (400, tool errors, + // etc.) should flow back to the agent for self-recovery. + if (/\b401\b/.test(result.error)) { + this.agent.emitError(result.error); + } } }) .catch((err) => { const message = err instanceof Error ? err.message : String(err); console.error(`[AsyncAgent] Agent run exception: ${message}`); this.channel.send({ id: uuidv7(), content: `[error] ${message}` }); - // Also emit through subscriber mechanism so IPC listeners receive the error - this.agent.emitError(message); + // Only emit agent_error for HTTP 401 from the LLM provider so the + // UI shows the "Configure" banner. All other errors (400, tool errors, + // etc.) should flow back to the agent for self-recovery. + const errMsg = err instanceof Error ? err.message : String(err); + if (/\b401\b/.test(errMsg)) { + this.agent.emitError(message); + } }) .finally(() => { this.pendingWrites = Math.max(0, this.pendingWrites - 1);