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

## 0.1.36 - 2026-08-23

MendCode v0.1.36 makes Loop and Workflow supervision durable, observable, and consistent with the workspace users actually inspect.

### Changed

- Show the interruption confirmation only after the first `Esc` press, on the left side of the activity row; a second `Esc` still targets the same active turn.
- Display completion-audit lifecycle, attempt, lease countdown or expiry age, last update, and the active auditor chat directly in the Workflow monitor.
- Open the current completion-auditor transcript before completed task transcripts while a Workflow audit is active.

### Fixed

- Reclaim expired completion-audit leases after restart even when every Workflow task is already complete, without falling back to a permanently queued run.
- Renew active completion-audit leases without increasing the attempt count, so long auditor turns cannot reject their own final evidence as stale.
- Avoid duplicate workspace-update events when a recovered Workflow reuses the same workspace lease.
- Execute Loop workers inside their authoritative leased worktree, preserve that path and branch through terminal state, and notify the parent chat with the exact workspace location.
- Keep Loop draft behavior aligned across legacy and Effect HTTP routes, including full contracts that omit optional cost or token caps.
- Prevent large transcripts from visibly unmounting when a submitted message moves into the optimistic render window.
- Preserve keyboard permission selection when OpenTUI reports synthetic or parked pointer movement.

### Tests

- Add focused regressions for audit restart recovery, duplicate lease events, auditor transcript routing, audit-state presentation, two-step interruption placement, Loop route parity, worktree ownership, large-session submission, and permission selection.
- Run 335 release-focused Loop, Workflow, TUI, session, permission, and route tests with isolated state, plus the native Darwin build and startup smoke checks.

## 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.
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.35"
version = "0.1.36"
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.35/mendcode-darwin-arm64.zip"
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.36/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.35/mendcode-darwin-x64.zip"
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.36/mendcode-darwin-x64.zip"
cmd = "./mendcode"
args = ["acp"]

[agent_servers.mendcode.targets.linux-aarch64]
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.35/mendcode-linux-arm64.tar.gz"
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.36/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.35/mendcode-linux-x64.tar.gz"
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.36/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.35/mendcode-windows-x64.zip"
archive = "https://github.com/MendCode/MendCode/releases/download/v0.1.36/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.35",
"version": "0.1.36",
"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.35"
const VERSION = "0.1.36"
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 @@ -718,8 +718,8 @@ export function sessionInterruptConfirmationAction(input: {
}

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]"
if (!input.enabled || !input.working || !input.armed) return
return "[esc again to interrupt]"
}

export function clipboardPasteAction(content: { mime?: string; data?: string } | undefined): "image" | "text" | "none" {
Expand Down Expand Up @@ -2639,11 +2639,6 @@ export function Prompt(props: PromptProps) {
const queuedBehindActiveTurn = workingStatusActive()
const submissionStartedAt = Date.now()
submitPending = true
setSubmitPreflightActive(true)
if (!queuedBehindActiveTurn) {
setWorkingStartedAt(submissionStartedAt)
if (props.sessionID) workingStartedAtBySession.set(props.sessionID, submissionStartedAt)
}
updateInterruptRequested(false)
const modelConfig = await readModelsConfig(mend.root).catch(() => undefined)
const configuredRole = Object.values(modelConfig?.roles || {}).find(
Expand Down Expand Up @@ -2745,10 +2740,6 @@ export function Prompt(props: PromptProps) {
local.model.variant.set(variant, { model: selectedModel })

updateInterruptRequested(false)
if (!queuedBehindActiveTurn) {
workingStartedAtBySession.set(sessionID, submissionStartedAt)
setWorkingStartedAt(submissionStartedAt)
}
const inputText = submittedPrompt.inputText
const nonTextParts = submittedPrompt.nonTextParts

Expand Down Expand Up @@ -2797,9 +2788,55 @@ 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.
const skillPromptParts =
slashInvocation && skillInvocation
? [
{
id: PartID.ascending(),
type: "text" as const,
text: `/${slashInvocation.name}${slashInvocation.arguments ? ` ${slashInvocation.arguments}` : ""}`,
},
{
id: PartID.ascending(),
type: "text" as const,
text: `Use the skill tool to load "${skillInvocation.name}", then follow its instructions for: ${slashInvocation.arguments}`,
synthetic: true,
metadata: {
kind: "skill_invocation",
skill: skillInvocation.name,
},
},
...supplementalSlashPromptParts(promptParts),
]
: undefined
const optimisticParts =
currentMode !== "shell" &&
!(slashInvocation && NATIVE_COMPACTION_SLASHES.has(slashInvocation.name)) &&
!(slashInvocation && slashServerCommand)
? (skillPromptParts ?? promptParts)
: undefined

// OpenTUI can render synchronously when either transcript or editor height
// changes. Commit the submitted-turn anchor and optimistic row together so
// virtualization never resolves an intermediate frame from stale scrollTop.
batch(() => {
setSubmitPreflightActive(true)
if (!queuedBehindActiveTurn) {
workingStartedAtBySession.set(sessionID, submissionStartedAt)
setWorkingStartedAt(submissionStartedAt)
}
props.onSubmit?.({ sessionID, messageID, inputRows: submittedInputRows, queuedBehindActiveTurn })
if (!optimisticParts) return
insertOptimisticUserTurn({
sessionID,
messageID,
agent: agent.name,
model: selectedModel,
variant,
created: Date.now(),
parts: optimisticParts,
})
})
clearPromptForSubmit()
history.append(
{
Expand Down Expand Up @@ -2838,43 +2875,14 @@ export function Prompt(props: PromptProps) {
})
})
} else if (slashInvocation && skillInvocation) {
const visible = `/${slashInvocation.name}${slashInvocation.arguments ? ` ${slashInvocation.arguments}` : ""}`
const skillPromptParts = [
{
id: PartID.ascending(),
type: "text" as const,
text: visible,
},
{
id: PartID.ascending(),
type: "text" as const,
text: `Use the skill tool to load "${skillInvocation.name}", then follow its instructions for: ${slashInvocation.arguments}`,
synthetic: true,
metadata: {
kind: "skill_invocation",
skill: skillInvocation.name,
},
},
...supplementalSlashPromptParts(promptParts),
]
const optimisticCreated = Date.now()
insertOptimisticUserTurn({
sessionID,
messageID,
agent: agent.name,
model: selectedModel,
variant,
created: optimisticCreated,
parts: skillPromptParts,
})
void deliverPrompt(
{
sessionID,
messageID,
agent: agent.name,
model: selectedModel,
variant,
parts: skillPromptParts,
parts: skillPromptParts!,
},
{ queuedBehindActiveTurn },
)
Expand All @@ -2896,16 +2904,6 @@ export function Prompt(props: PromptProps) {
})),
})
} else {
const optimisticCreated = Date.now()
insertOptimisticUserTurn({
sessionID,
messageID,
agent: agent.name,
model: selectedModel,
variant,
created: optimisticCreated,
parts: promptParts,
})
void deliverPrompt(
{
sessionID,
Expand All @@ -2919,7 +2917,6 @@ export function Prompt(props: PromptProps) {
)
if (editorParts.length > 0) editor.markSelectionSent()
}
props.onSubmit?.({ sessionID, messageID, inputRows: submittedInputRows, queuedBehindActiveTurn })
renderer.requestRender()

if (props.sessionID) submitPending = false
Expand Down Expand Up @@ -3888,6 +3885,13 @@ export function Prompt(props: PromptProps) {
paddingRight={promptFooterPadRight()}
>
<box flexDirection="row" gap={1} flexShrink={1}>
<Show when={interruptHint()}>
{(hint) => (
<text fg={theme.warning} wrapMode="none">
{hint()}
</text>
)}
</Show>
<Show when={connectionStateMessage()}>
{(message) => (
<text fg={theme.warning} wrapMode="none">
Expand Down Expand Up @@ -3955,13 +3959,6 @@ 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
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,7 @@ export function Session() {
}),
)
const [permissionModeSetting, setPermissionModeSetting] = createSignal<PermissionMode>("approval")
const [permissionSelection, setPermissionSelection] = createSignal<{ requestID: string; option: string }>()
const children = createMemo(() => {
const parentID = session()?.parentID ?? session()?.id
return sync.data.session
Expand Down Expand Up @@ -791,6 +792,7 @@ export function Session() {
if (permissionModeSetting() === "full_access") return []
return pendingPermissions()
})
const activePermission = createMemo(() => permissions()[0])
const questions = createMemo(() => {
return pendingInputSessionIDs().flatMap((sessionID) => sync.data.question[sessionID] ?? [])
})
Expand Down Expand Up @@ -2398,10 +2400,17 @@ export function Session() {
if (!scroll || scroll.isDestroyed) return false
renderer.requestRender()
const child = scroll.getChildren().find((item) => item.id === intent.messageID)
if (!child) return false
// A freshly reconciled OpenTUI child exists before Yoga has assigned its
// final geometry. Detaching sticky-follow while that child is still at
// y=0 makes the first frame jump to the oldest loaded transcript row.
if (!child || child.height <= 0 || child.y < scroll.y) return false
applyingSubmitScroll = true
const delta = child.y - scroll.y - intent.offset
if (delta !== 0) scroll.scrollBy(delta)
// Keep sticky-follow active until the optimistic child is mounted and the
// identity offset has been applied. Detaching earlier lets OpenTUI reset a
// resized long transcript to the first loaded row for one visible frame.
setFollowSessionOutput(false)
applyingSubmitScroll = false
lastObservedScrollTop = scroll.scrollTop
lastObservedScrollHeight = scroll.scrollHeight
Expand All @@ -2413,7 +2422,10 @@ export function Session() {

const scheduleSubmitScrollIntent = () => {
const token = ++submitScrollToken
;[0, 16, 50, 120, 240, 480].forEach((delay) => {
// Keep sticky-follow through the first layout frame. The first retry runs
// only after Yoga has positioned the optimistic row, then pins that row
// by identity without exposing the bounded store's oldest row.
;[16, 50, 120, 240, 480].forEach((delay) => {
setTimeout(() => reconcileSubmitScrollIntent(token), delay)
})
}
Expand All @@ -2427,7 +2439,6 @@ export function Session() {
scrollAnchor = undefined
suppressedPagingBoundary = undefined
restoringSessionScroll = false
setFollowSessionOutput(false)
setSubmitScrollIntent({ sessionID: input.sessionID, messageID: input.messageID, offset: input.offset ?? 0 })
scheduleSubmitScrollIntent()
}
Expand Down Expand Up @@ -4724,8 +4735,18 @@ export function Session() {
</Show>
</box>
<box flexShrink={0} width="100%">
<Show when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} />
<Show when={activePermission()}>
{(request) => (
<PermissionPrompt
request={request()}
selected={
permissionSelection()?.requestID === request().id ? permissionSelection()?.option : undefined
}
onSelectionChange={(option) =>
setPermissionSelection({ requestID: request().id, option })
}
/>
)}
</Show>
<Show when={permissions().length === 0 && questions().length > 0}>
<QuestionPrompt request={questions()[0]} />
Expand Down
Loading
Loading