Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
# Changelog

## 0.1.35 - 2026-08-22

MendCode v0.1.35 makes long-running sessions, Loop execution, Usage Insights, and local agent collaboration more immediate and reliable.

### Added

- Add a customizable Usage Insights widget layout with persisted visibility controls and a subscription-usage widget backed by local Codex rate-limit telemetry.
- Add same-project peer discovery and bounded, approval-gated peer messages that preserve visible source provenance without granting tool permissions.

### Changed

- Require a second `Esc` press for the same active turn before interruption, with a visible confirmation window that remains responsive during clipboard inspection and compaction.
- Keep Loop workers fail-closed after an unfinished tool attempt instead of automatically replaying work with unknown side effects.
- Refresh large session transcripts, Git status, selected-day statistics, and Loop chat activity without blocking the visible interface on expensive auxiliary data.
- Display accurate background follow-up wording instead of reporting that an agent resumed when detached work merely completed.

### Fixed

- Prevent prompt submission from briefly unmounting or blanking a large transcript before the optimistic user turn is rendered.
- Prevent overlapping Loop daemon ticks, restore project-scoped Loop discovery for isolated worktrees, and accept canonical macOS temporary-directory aliases in permission checks.
- Keep selected Usage Insights days current until manually pinned, then update arrow-key navigation synchronously without remounting the dashboard.
- Preserve keyboard permission selection during synthetic pointer movement and keep compact reasoning, transcript spacing, and grouped tool continuation presentation stable.

### Tests

- Add focused regressions for two-step interruption, prompt mounting, transcript sync and virtualization, Loop scheduling and recovery, permission path aliases, Usage Insights widgets, subscription telemetry, and peer messaging.
- Run the release-focused agent, session, Loop, TUI, stats, collaboration, and tool suites with isolated test state.

## 0.1.34 - 2026-08-20

MendCode v0.1.34 fixes remaining session presentation regressions from v0.1.33.
Expand Down
12 changes: 6 additions & 6 deletions src/mendcode/packages/extensions/zed/extension.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
id = "mendcode"
name = "MendCode"
description = "The open source coding agent."
version = "0.1.34"
version = "0.1.35"
schema_version = 1
authors = ["MendCode"]
repository = "https://github.com/MendCode/MendCode"
Expand All @@ -11,26 +11,26 @@ name = "MendCode"
icon = "./icons/opencode.svg"

[agent_servers.mendcode.targets.darwin-aarch64]
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.34/mendcode-darwin-arm64.zip"
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.35/mendcode-darwin-arm64.zip"
cmd = "./mendcode"
args = ["acp"]

[agent_servers.mendcode.targets.darwin-x86_64]
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.34/mendcode-darwin-x64.zip"
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.35/mendcode-darwin-x64.zip"
cmd = "./mendcode"
args = ["acp"]

[agent_servers.mendcode.targets.linux-aarch64]
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.34/mendcode-linux-arm64.tar.gz"
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.35/mendcode-linux-arm64.tar.gz"
cmd = "./mendcode"
args = ["acp"]

[agent_servers.mendcode.targets.linux-x86_64]
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.34/mendcode-linux-x64.tar.gz"
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.35/mendcode-linux-x64.tar.gz"
cmd = "./mendcode"
args = ["acp"]

[agent_servers.mendcode.targets.windows-x86_64]
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.34/mendcode-windows-x64.zip"
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.35/mendcode-windows-x64.zip"
cmd = "./mendcode.exe"
args = ["acp"]
2 changes: 1 addition & 1 deletion src/mendcode/packages/opencode/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.1.34",
"version": "0.1.35",
"name": "mendcode",
"type": "module",
"license": "MIT",
Expand Down
2 changes: 1 addition & 1 deletion src/mendcode/packages/opencode/script/dev-rearm-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
import { once } from "node:events"
import { probeRuntime } from "./dev-rearm"

const VERSION = "0.1.34"
const VERSION = "0.1.35"
const PROJECT = "/tmp/mendcode-dev-rearm-smoke-project"

function assert(condition: unknown, message: string): asserts condition {
Expand Down
7 changes: 5 additions & 2 deletions src/mendcode/packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import PROMPT_TITLE from "./prompt/title.txt"
import { Permission } from "@/permission"
import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@mendcode/core/global"
import { AppFileSystem } from "@mendcode/core/filesystem"
import path from "path"
import { Plugin } from "@/plugin"
import { Skill } from "../skill"
Expand Down Expand Up @@ -81,10 +82,12 @@ export const layer = Layer.effect(
Effect.fn("Agent.state")(function* (ctx) {
const cfg = yield* config.get()
const skillDirs = yield* skill.dirs()
const whitelistedRoots = [Global.Path.tmp, ...skillDirs]
const whitelistedDirs = [
Truncate.GLOB,
path.join(Global.Path.tmp, "*"),
...skillDirs.map((dir) => path.join(dir, "*")),
...new Set(
whitelistedRoots.flatMap((dir) => [dir, AppFileSystem.resolve(dir)]).map((dir) => path.join(dir, "*")),
),
]

const defaults = Permission.fromConfig({
Expand Down
22 changes: 13 additions & 9 deletions src/mendcode/packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -977,7 +977,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; onDiagnostics?: () =
? "Workflows"
: route.data.type === "session-history"
? "Session History"
: route.data.type
: route.data.type
const sessionLabel = session && !SessionApi.isDefaultTitle(session.title) ? session.title : routeLabel
if (
route.data.type === "home" &&
Expand Down Expand Up @@ -1092,10 +1092,10 @@ function App(props: { onSnapshot?: () => Promise<string[]>; onDiagnostics?: () =
if (readiness.aiReady) return
if (
shouldShowFirstRunIntro({
interactive: true,
setupComplete: isSetupComplete(state),
dismissed: Boolean(state.dismissedAt),
seen: kv.get(FIRST_RUN_INTRO_SEEN_KEY, false) === true,
interactive: true,
setupComplete: isSetupComplete(state),
dismissed: Boolean(state.dismissedAt),
seen: kv.get(FIRST_RUN_INTRO_SEEN_KEY, false) === true,
})
) {
dialog.clear()
Expand Down Expand Up @@ -4814,7 +4814,11 @@ function App(props: { onSnapshot?: () => Promise<string[]>; onDiagnostics?: () =
{
title: "Help",
value: "help.show",
keybind: ["setup", "stats", "memory", "changes", "loops", "workflows", "session-history"].includes(route.data.type) ? "help" : undefined,
keybind: ["setup", "stats", "memory", "changes", "loops", "workflows", "session-history"].includes(
route.data.type,
)
? "help"
: undefined,
slash: {
name: "help",
},
Expand Down Expand Up @@ -5054,10 +5058,10 @@ function App(props: { onSnapshot?: () => Promise<string[]>; onDiagnostics?: () =
const titles = evt.properties.taskTitles?.filter(Boolean) ?? []
const count = evt.properties.taskIDs?.length ?? titles.length
toast.show({
title: "Agent resumed",
message: `${titles.join(", ") || `${count} background task${count === 1 ? "" : "s"}`} finished; the runtime event was delivered internally`,
title: "Background work finished",
message: `${titles.join(", ") || `${count} background task${count === 1 ? "" : "s"}`} finished; an automatic follow-up started`,
variant: "info",
duration: 5_000,
duration: 8_000,
})
return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -581,13 +581,7 @@ export function latestPendingAssistantID(
// still owns the turn or one of its tools is genuinely pending/running.
// Question rejection is persisted as a completed assistant/tool error and
// an idle session; treating that receipt as live caused ghost Generating.
if (
latestAssistant.time.completed &&
continuation &&
input?.statusType === "idle" &&
!input.hasActiveTool
)
return
if (latestAssistant.time.completed && continuation && input?.statusType === "idle" && !input.hasActiveTool) return
if (
input &&
!isAssistantWorking({
Expand Down Expand Up @@ -690,20 +684,42 @@ export function shouldEnableSessionInterrupt(input: {
}) {
if (input.interruptRequested) return false
// An active turn owns Esc even when autocomplete/another prompt overlay has
// focus. The backend cancellation must not wait for the overlay to close.
if (input.statusType !== "idle" || input.hasActiveWorkingAssistant || input.hasPendingPromptDelivery) return true
// focus, so the first press can arm cancellation without closing the overlay.
if (
input.statusType !== "idle" ||
input.hasActiveWorkingAssistant ||
input.hasPendingPromptDelivery ||
input.compactionActive
)
return true
if (input.autocompleteVisible) return false
return false
}

export function shouldInterruptImmediately(input: {
export function hasInterruptibleSessionTurn(input: {
statusType: string
hasDraft?: boolean
hasActiveWorkingAssistant?: boolean
hasPendingPromptDelivery?: boolean
compactionActive?: boolean
}) {
return input.statusType !== "idle" || Boolean(input.hasActiveWorkingAssistant || input.hasPendingPromptDelivery)
return (
input.statusType !== "idle" ||
Boolean(input.hasActiveWorkingAssistant || input.hasPendingPromptDelivery || input.compactionActive)
)
}

export function sessionInterruptConfirmationAction(input: {
activeTargetMessageID?: string
armedTargetMessageID?: string
}): "ignore" | "arm" | "interrupt" {
if (!input.activeTargetMessageID) return "ignore"
return input.armedTargetMessageID === input.activeTargetMessageID ? "interrupt" : "arm"
}

export function sessionInterruptHint(input: { enabled: boolean; working: boolean; armed: boolean }) {
if (!input.enabled || !input.working) return
return input.armed ? "[esc again to interrupt]" : "[esc to interrupt]"
}

export function clipboardPasteAction(content: { mime?: string; data?: string } | undefined): "image" | "text" | "none" {
Expand Down Expand Up @@ -893,6 +909,7 @@ export function Prompt(props: PromptProps) {
let promptDisposed = false
let interruptRequest: Promise<unknown> | undefined
let interruptResetTimer: ReturnType<typeof setTimeout> | undefined
let interruptArmedTargetMessageID: string | undefined
let autocomplete: AutocompleteRef
let lastPastedContentClick: { time: number; offset: number } | undefined

Expand Down Expand Up @@ -1616,6 +1633,19 @@ export function Prompt(props: PromptProps) {
extmarkToPartIndex: new Map(),
interrupt: 0,
})
function resetInterruptConfirmation() {
interruptArmedTargetMessageID = undefined
setStore("interrupt", 0)
if (!interruptResetTimer) return
clearTimeout(interruptResetTimer)
interruptResetTimer = undefined
}
createEffect(() => {
if (store.interrupt === 0) return
const activeTargetMessageID = workingStatusActive() ? activeTurnTargetMessageID() : undefined
if (activeTargetMessageID === interruptArmedTargetMessageID) return
resetInterruptConfirmation()
})
const currentMessageHistoryPrompt = createMemo(() =>
messageHistoryIndex() === 0 ? undefined : messageHistoryItems().at(messageHistoryIndex()),
)
Expand Down Expand Up @@ -1740,16 +1770,16 @@ export function Prompt(props: PromptProps) {
}),
}),
onSelect: (dialog) => {
const immediateInterrupt = shouldInterruptImmediately({
const hasActiveTurn = hasInterruptibleSessionTurn({
statusType: status().type,
hasDraft: store.prompt.input.trim().length > 0 || store.prompt.parts.length > 0,
hasActiveWorkingAssistant: hasActiveWorkingAssistant(),
hasPendingPromptDelivery: Boolean(props.sessionID && pendingPromptDeliveryIsActive(props.sessionID)),
compactionActive: compactionActive(),
})
if (autocomplete?.visible && !immediateInterrupt) return
if (autocomplete?.visible && !hasActiveTurn) return
if (
!immediateInterrupt &&
!hasActiveTurn &&
!shouldAcceptPromptInterruptFocus({
inputFocused: input.focused,
currentFocusedRenderable: renderer.currentFocusedRenderable,
Expand All @@ -1763,31 +1793,26 @@ export function Prompt(props: PromptProps) {
return
}
if (!props.sessionID) return
if (immediateInterrupt) {
const targetMessageID = activeTurnTargetMessageID()
const targetMessageID = activeTurnTargetMessageID()
const action = sessionInterruptConfirmationAction({
activeTargetMessageID: targetMessageID,
armedTargetMessageID: interruptArmedTargetMessageID,
})
if (action === "ignore") return
if (action === "interrupt") {
cancelPendingPromptDeliveryForInterrupt(props.sessionID, targetMessageID)
abortSession(props.sessionID, targetMessageID)
setStore("interrupt", 0)
resetInterruptConfirmation()
dialog.clear()
return
}

const nextInterrupt = store.interrupt + 1
setStore("interrupt", nextInterrupt)
interruptArmedTargetMessageID = targetMessageID
setStore("interrupt", 1)
if (interruptResetTimer) clearTimeout(interruptResetTimer)
interruptResetTimer = setTimeout(() => {
setStore("interrupt", 0)
interruptResetTimer = undefined
resetInterruptConfirmation()
}, 5000)

if (nextInterrupt >= 2) {
const targetMessageID = activeTurnTargetMessageID()
cancelPendingPromptDeliveryForInterrupt(props.sessionID, targetMessageID)
abortSession(props.sessionID, targetMessageID)
setStore("interrupt", 0)
clearTimeout(interruptResetTimer)
interruptResetTimer = undefined
}
dialog.clear()
},
},
Expand Down Expand Up @@ -2620,8 +2645,6 @@ export function Prompt(props: PromptProps) {
if (props.sessionID) workingStartedAtBySession.set(props.sessionID, submissionStartedAt)
}
updateInterruptRequested(false)
clearPromptForSubmit()
renderer.requestRender()
const modelConfig = await readModelsConfig(mend.root).catch(() => undefined)
const configuredRole = Object.values(modelConfig?.roles || {}).find(
(role) => role?.providerID === selectedModel.providerID && role?.modelID === selectedModel.modelID,
Expand Down Expand Up @@ -2774,6 +2797,10 @@ export function Prompt(props: PromptProps) {
throw error
}

// Do not expose a render between clearing the editor and mounting the
// optimistic turn. On large transcripts that intermediate layout pass can
// briefly leave the scrollbox with only virtual spacers on screen.
clearPromptForSubmit()
history.append(
{
...promptSnapshot,
Expand Down Expand Up @@ -2892,13 +2919,8 @@ export function Prompt(props: PromptProps) {
)
if (editorParts.length > 0) editor.markSelectionSent()
}
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
props.onSubmit?.({ sessionID, messageID, inputRows: submittedInputRows, queuedBehindActiveTurn })
renderer.requestRender()

if (props.sessionID) submitPending = false

Expand Down Expand Up @@ -3673,6 +3695,13 @@ export function Prompt(props: PromptProps) {
working: workingStatusActive(),
}),
)
const interruptHint = createMemo(() => {
return sessionInterruptHint({
enabled: workingIndicatorConfig().showInterruptHint,
working: workingStatusActive(),
armed: store.interrupt > 0,
})
})
const promptInputPadTop = createMemo(() => {
if (promptChrome().preset === "minimal") return 1
if (promptUsesPanelBackground()) return 0
Expand All @@ -3691,7 +3720,7 @@ export function Prompt(props: PromptProps) {
const workingLeftMaxWidth = createMemo(() => {
const rightMetaWidth = workingRightMeta()?.length ?? 0
const mascotWidth = 0
const interruptWidth = workingIndicatorConfig().showInterruptHint ? "[esc again to interrupt]".length : 0
const interruptWidth = interruptHint()?.length ?? 0
const separatorWidth = [rightMetaWidth, interruptWidth, mascotWidth].filter(Boolean).length * 2
return Math.max(
12,
Expand Down Expand Up @@ -3926,6 +3955,13 @@ export function Prompt(props: PromptProps) {
</Show>
</box>
<box flexDirection="row" gap={2} flexShrink={0}>
<Show when={interruptHint()}>
{(hint) => (
<text fg={store.interrupt > 0 ? theme.warning : theme.textMuted} wrapMode="none">
{hint()}
</text>
)}
</Show>
<Show when={status().type !== "retry" && workingStatusActive() && workingRightMeta()}>
{(meta) => (
<text fg={theme.textMuted} wrapMode="none">
Expand Down
Loading
Loading