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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# Changelog

## 0.1.38 - 2026-08-25

MendCode v0.1.38 hardens Smart Approval and keeps long-running sessions truthful during retries, reconnects, and large TUI turns.

### Changed

- Bind Smart Approval to the exact tool call and originating user turn instead of the latest prompt in the session.
- Keep bounded workspace inspections for explicit sync, audit, and inspect tasks moving without broadening writes or external-directory access.
- Show distinct retry states for the MendCode backend and the AI/provider backend while live heartbeats are fresh.

### Fixed

- Fail closed for writes, directory creation, absolute or traversal paths, scripts, validators, unknown executables, and misleading `--dry-run` or `--check` flags.
- Prevent duplicate or stale Smart Approval replies when multiple TUI routes observe the same pending request.
- Keep busy/retry activity visible through fresh heartbeats without persisting heartbeat churn to SQLite.
- Reconcile terminal assistant turns so stale Generating, retrying, and agent-state labels do not survive a completed or interrupted response.

### Tests

- Run 275 focused TUI, session, permission, Smart Approval, and schema regressions with 0 failures.

## 0.1.37 - 2026-08-23

MendCode v0.1.37 prevents orphaned macOS Loop services and makes Workflow completion recovery bounded and executable.
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.37"
version = "0.1.38"
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.37/mendcode-darwin-arm64.zip"
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.38/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.37/mendcode-darwin-x64.zip"
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.38/mendcode-darwin-x64.zip"
cmd = "./mendcode"
args = ["acp"]

[agent_servers.mendcode.targets.linux-aarch64]
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.37/mendcode-linux-arm64.tar.gz"
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.38/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.37/mendcode-linux-x64.tar.gz"
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.38/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.37/mendcode-windows-x64.zip"
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.38/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.37",
"version": "0.1.38",
"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.37"
const VERSION = "0.1.38"
const PROJECT = "/tmp/mendcode-dev-rearm-smoke-project"

function assert(condition: unknown, message: string): asserts condition {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ import {
terminalAssistantSettlesActivity,
SESSION_AGENT_STATE_UNKNOWN_MESSAGE,
displayConnectionStatus,
retryStatusMessage,
shouldShowAgentStateUnknown,
} from "../../util/session-working"

Expand Down Expand Up @@ -567,6 +568,7 @@ export function latestPendingAssistantID(
input?: {
statusType?: string
now?: number
statusHeartbeatAt?: number
statusUntil?: number
statusNext?: number
hasActiveTool?: boolean
Expand All @@ -588,6 +590,7 @@ export function latestPendingAssistantID(
statusType: input.statusType,
now: input.now,
assistantCreated: latestAssistant.time.created,
statusHeartbeatAt: input.statusHeartbeatAt,
statusUntil: input.statusUntil,
statusNext: input.statusNext,
hasActiveTool: input.hasActiveTool,
Expand Down Expand Up @@ -1037,6 +1040,8 @@ export function Prompt(props: PromptProps) {
const activeID = latestPendingAssistantID(messages, {
statusType: currentStatus.type,
now: promptStatusTick(),
statusHeartbeatAt:
currentStatus.type === "busy" || currentStatus.type === "retry" ? currentStatus.heartbeatAt : undefined,
statusUntil: currentStatus.type === "busy" ? currentStatus.until : undefined,
statusNext: currentStatus.type === "retry" ? currentStatus.next : undefined,
hasActiveTool,
Expand Down Expand Up @@ -3738,19 +3743,11 @@ export function Prompt(props: PromptProps) {
const workingConnectionMessage = createMemo(() => {
const connection = sdk.connection
const effectiveStatus = effectiveConnectionStatus()
if (
knownAgentActivityConnectionLabel({
connectionStatus: effectiveStatus,
hasKnownAgentActivity: hasKnownAgentActivity(),
attempt: connection.attempt,
})
)
return
if (effectiveStatus === "connecting") return "connecting to MendCode..."
if (effectiveStatus === "connecting") return "connecting to MendCode backend..."
if (effectiveStatus === "reconnecting")
return `reconnecting to MendCode${connection.attempt > 1 ? ` #${connection.attempt}` : ""}...`
if (effectiveStatus === "failed") return `connection lost after ${connection.attempt} reconnect attempts`
if (effectiveStatus === "disconnected") return "disconnected from MendCode"
return `retrying MendCode backend${connection.attempt > 1 ? ` #${connection.attempt}` : ""}...`
if (effectiveStatus === "failed") return `MendCode backend unavailable after ${connection.attempt} retries`
if (effectiveStatus === "disconnected") return "MendCode backend disconnected"

return
})
Expand Down Expand Up @@ -3944,7 +3941,7 @@ export function Prompt(props: PromptProps) {
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const baseMessage = retryStatusMessage(message())
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1194,7 +1194,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const statusUntil = "until" in status && typeof status.until === "number" ? status.until : undefined
if (currentUntil !== statusUntil) return
}
if (current.type === "retry" && status.type === "retry" && current.next !== status.next) return
if (current.type === "retry" && status.type === "retry") {
if (current.next !== status.next) return
if (current.heartbeatAt !== status.heartbeatAt) return
}
setStore("session_status", sessionID, { type: "idle" })
}, delay),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,22 @@ const SESSION_LIVE_FOLLOW_EVENTS = new Set([
])

const sessionScrollStates = new Map<string, SessionScrollState>()
// Multiple TUI routes can observe the same shared pending permission. Keep a
// process-local lease so they do not run duplicate reviewer calls in parallel;
// the fingerprint check below still protects the reply from a changed request.
const smartPermissionReviewLeases = new Set<string>()

function smartPermissionRequestFingerprint(request: PermissionRequest) {
return JSON.stringify({
sessionID: request.sessionID,
permission: request.permission,
patterns: request.patterns,
always: request.always,
command: typeof request.metadata.command === "string" ? request.metadata.command : undefined,
source: typeof request.metadata.source === "string" ? request.metadata.source : undefined,
tool: request.tool,
})
}

export function sessionFollowSyncKind(type: string) {
if (SESSION_IMMEDIATE_FOLLOW_EVENTS.has(type)) return "immediate" as const
Expand Down Expand Up @@ -834,6 +850,8 @@ export function Session() {
return latestPendingAssistantID(activityMessages(), {
statusType: currentStatus?.type,
now: now(),
statusHeartbeatAt:
currentStatus?.type === "busy" || currentStatus?.type === "retry" ? currentStatus.heartbeatAt : undefined,
statusUntil: currentStatus?.type === "busy" ? currentStatus.until : undefined,
statusNext: currentStatus?.type === "retry" ? currentStatus.next : undefined,
hasActiveTool,
Expand Down Expand Up @@ -1535,18 +1553,21 @@ export function Session() {
async function smartReviewPendingPermissions() {
let reviewed = 0
for (const request of permissions()) {
if (smartReviewedPermissionIDs.has(request.id)) continue
if (smartReviewedPermissionIDs.has(request.id) || smartPermissionReviewLeases.has(request.id)) continue
if (!shouldReviewSmartApproval(request)) {
setSmartPermissionStatus("Smart needs your approval")
continue
}
smartReviewedPermissionIDs.add(request.id)
smartPermissionReviewLeases.add(request.id)
const requestFingerprint = smartPermissionRequestFingerprint(request)
try {
setSmartPermissionStatus(`Smart reviewing ${request.permission}`)
const prompt = sessionUserPromptHistory({
const prompt = sessionUserPromptForPermissionRequest({
messages: sync.data.message[request.sessionID] ?? [],
partsByMessage: sync.data.part,
}).findLast((item) => item.input.trim().length > 0)?.input
messageID: request.tool?.messageID,
})?.input
const decision = await reviewPermissionRequestWithModel(request, mend.root, { userPrompt: prompt })
if (!decision.triggered || decision.decision === "ask") {
setSmartPermissionStatus(`Smart needs approval`)
Expand All @@ -1557,6 +1578,16 @@ export function Session() {
})
continue
}
const currentRequest = permissions().find((item) => item.id === request.id)
if (!currentRequest || smartPermissionRequestFingerprint(currentRequest) !== requestFingerprint) {
smartReviewedPermissionIDs.delete(request.id)
toast.show({
message: "Smart Approval discarded a stale permission request; it will be reviewed again if still pending.",
variant: "info",
duration: 5000,
})
continue
}
reviewed++
await sdk.client.permission.reply({
reply: decision.decision === "allow" ? "once" : "reject",
Expand All @@ -1575,6 +1606,8 @@ export function Session() {
} catch (error) {
smartReviewedPermissionIDs.delete(request.id)
throw error
} finally {
smartPermissionReviewLeases.delete(request.id)
}
}
return { accepted: 0, reviewed }
Expand Down Expand Up @@ -5054,7 +5087,15 @@ type SessionSubagentInfo = {
}

function sessionLiveStateLabel(input: {
status?: { type: string; kind?: string; attempt?: number; message?: string; next?: number; startedAt?: number }
status?: {
type: string
kind?: string
attempt?: number
message?: string
next?: number
startedAt?: number
heartbeatAt?: number
}
messages: Message[]
pendingInputCount: number
now?: number
Expand All @@ -5072,7 +5113,12 @@ function sessionLiveStateLabel(input: {
if (
input.status?.type === "retry" &&
!terminalAssistantSettles &&
isAssistantWorking({ statusType: input.status.type, now: input.now, statusNext: input.status.next })
isAssistantWorking({
statusType: input.status.type,
now: input.now,
statusNext: input.status.next,
statusHeartbeatAt: input.status.heartbeatAt,
})
)
return input.status.attempt && input.status.attempt > 1 ? `retry #${input.status.attempt}` : "retrying"
const lastUser = input.messages.findLast((message) => message.role === "user")
Expand All @@ -5085,6 +5131,7 @@ function sessionLiveStateLabel(input: {
statusKind: input.status.kind,
now: input.now,
assistantCreated: lastAssistant?.time.created,
statusHeartbeatAt: input.status.heartbeatAt,
statusUntil: (input.status as { until?: number }).until,
hasActiveTool: input.hasActiveTool,
})
Expand All @@ -5093,7 +5140,12 @@ function sessionLiveStateLabel(input: {
if (
lastAssistant &&
!lastAssistant.time.completed &&
isAssistantWorking({ now: input.now, assistantCreated: lastAssistant.time.created })
isAssistantWorking({
now: input.now,
assistantCreated: lastAssistant.time.created,
statusHeartbeatAt:
input.status?.type === "busy" || input.status?.type === "retry" ? input.status.heartbeatAt : undefined,
})
)
return "working"
if (
Expand Down Expand Up @@ -5176,6 +5228,33 @@ export function sessionUserPromptHistory(input: {
})
}

/**
* Resolve the user turn that caused a permission request. A permission request
* carries the assistant tool message ID, whose parentID is the exact user
* message. Falling back to the latest prompt would let a later, unrelated
* request authorize an earlier command.
*/
export function sessionUserPromptForPermissionRequest(input: {
messages: ReadonlyArray<Pick<Message, "id" | "role"> & { parentID?: string }>
partsByMessage: Record<string, readonly { type: string }[] | undefined>
messageID?: string
}) {
if (!input.messageID) return
const toolMessage = input.messages.find((message) => message.id === input.messageID)
if (!toolMessage) return
const userMessageID = toolMessage.role === "assistant" ? toolMessage.parentID : toolMessage.id
if (!userMessageID) return
const userMessage = input.messages.find((message) => message.id === userMessageID && message.role === "user")
if (!userMessage) return
const prompt = restorePromptFromSubmittedParts(
(input.partsByMessage[userMessage.id] ?? []).filter(
(part) => part.type === "text" || part.type === "file" || part.type === "agent",
),
)
if (prompt.input.length === 0 && prompt.parts.length === 0) return
return prompt
}

export function latestFullSessionHistoryStartID(
messages: Array<{
id: string
Expand Down
Loading
Loading