diff --git a/CHANGELOG.md b/CHANGELOG.md index c35ed5cc..709e9588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/mendcode/packages/extensions/zed/extension.toml b/src/mendcode/packages/extensions/zed/extension.toml index c0d66d8e..a9a35f8d 100644 --- a/src/mendcode/packages/extensions/zed/extension.toml +++ b/src/mendcode/packages/extensions/zed/extension.toml @@ -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" @@ -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"] diff --git a/src/mendcode/packages/opencode/package.json b/src/mendcode/packages/opencode/package.json index 0c0cd171..b59f4d4c 100644 --- a/src/mendcode/packages/opencode/package.json +++ b/src/mendcode/packages/opencode/package.json @@ -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", diff --git a/src/mendcode/packages/opencode/script/dev-rearm-smoke.ts b/src/mendcode/packages/opencode/script/dev-rearm-smoke.ts index a6754e52..b871ab03 100644 --- a/src/mendcode/packages/opencode/script/dev-rearm-smoke.ts +++ b/src/mendcode/packages/opencode/script/dev-rearm-smoke.ts @@ -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 { diff --git a/src/mendcode/packages/opencode/src/agent/agent.ts b/src/mendcode/packages/opencode/src/agent/agent.ts index 7e5293d9..d3bbbaeb 100644 --- a/src/mendcode/packages/opencode/src/agent/agent.ts +++ b/src/mendcode/packages/opencode/src/agent/agent.ts @@ -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" @@ -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({ diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/app.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/app.tsx index ef4e168e..c47b37db 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/app.tsx @@ -977,7 +977,7 @@ function App(props: { onSnapshot?: () => Promise; 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" && @@ -1092,10 +1092,10 @@ function App(props: { onSnapshot?: () => Promise; 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() @@ -4814,7 +4814,11 @@ function App(props: { onSnapshot?: () => Promise; 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", }, @@ -5054,10 +5058,10 @@ function App(props: { onSnapshot?: () => Promise; 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 } diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 34f7f406..b27159b5 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -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({ @@ -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" { @@ -893,6 +909,7 @@ export function Prompt(props: PromptProps) { let promptDisposed = false let interruptRequest: Promise | undefined let interruptResetTimer: ReturnType | undefined + let interruptArmedTargetMessageID: string | undefined let autocomplete: AutocompleteRef let lastPastedContentClick: { time: number; offset: number } | undefined @@ -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()), ) @@ -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, @@ -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() }, }, @@ -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, @@ -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, @@ -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 @@ -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 @@ -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, @@ -3926,6 +3955,13 @@ export function Prompt(props: PromptProps) { + + {(hint) => ( + 0 ? theme.warning : theme.textMuted} wrapMode="none"> + {hint()} + + )} + {(meta) => ( diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 444011cb..113c662d 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -586,6 +586,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sessionMessageSyncGeneration = new Map() const sessionMessageSyncInFlight = new Map>() const sessionMessageSyncQueued = new Set() + const sessionDiffHydrationRequested = new Set() + const sessionDiffHydrationInFlight = new Map>() const sessionCacheSeen = new Set() let activeSessionID: string | undefined const pinnedSessionMessages = new Map>() @@ -623,7 +625,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const recentStart = Date.now() - 30 * 24 * 60 * 60 * 1000 const sort = (items: typeof store.session) => items.toSorted((a, b) => a.id.localeCompare(b.id)) const attempts: Array<() => Promise> = [ - () => sdk.client.session.list({ start: recentStart, ...query }, { throwOnError: true }).then((x) => x.data ?? []), + () => + sdk.client.session.list({ start: recentStart, ...query }, { throwOnError: true }).then((x) => x.data ?? []), () => sdk.client.session.list(query, { throwOnError: true }).then((x) => x.data ?? []), ] @@ -725,6 +728,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } } + function hydrateSessionDiff(sessionID: string) { + if (sessionDiffHydrationRequested.has(sessionID)) return + sessionDiffHydrationRequested.add(sessionID) + const run: Promise = sdk.client.session + .diff({ sessionID }) + .then((diff) => { + if (sessionDiffHydrationInFlight.get(sessionID) !== run) return + setStore("session_diff", sessionID, reconcile(diff.data ?? [])) + }) + .catch(() => undefined) + .finally(() => { + if (sessionDiffHydrationInFlight.get(sessionID) === run) sessionDiffHydrationInFlight.delete(sessionID) + }) + sessionDiffHydrationInFlight.set(sessionID, run) + } + async function fetchSessionMessageWindow(sessionID: string) { const first = await fetchSessionMessagePage({ sessionID, limit: TUI_SESSION_MESSAGE_SYNC_LIMIT }) let items = first.items @@ -939,6 +958,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sessionMessagePaging.delete(sessionID) sessionMessageSyncGeneration.delete(sessionID) sessionMessageSyncQueued.delete(sessionID) + sessionDiffHydrationRequested.delete(sessionID) + sessionDiffHydrationInFlight.delete(sessionID) pinnedSessionMessages.delete(sessionID) removedSessionMessages.delete(sessionID) clearSessionStatusTimer(sessionID) @@ -1206,6 +1227,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sessionMessagePaging.clear() sessionMessageSyncGeneration.clear() sessionMessageSyncQueued.clear() + sessionDiffHydrationRequested.clear() + sessionDiffHydrationInFlight.clear() pendingPartDeltas.clear() fullMessageRequests.clear() pinnedSessionMessages.clear() @@ -1404,6 +1427,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break case "session.diff": + sessionDiffHydrationRequested.add(event.properties.sessionID) + sessionDiffHydrationInFlight.delete(event.properties.sessionID) setStore("session_diff", event.properties.sessionID, event.properties.diff) break @@ -1447,6 +1472,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (activeSessionID === sessionID) activeSessionID = undefined sessionMessagePaging.delete(sessionID) sessionMessageSyncQueued.delete(sessionID) + sessionDiffHydrationRequested.delete(sessionID) + sessionDiffHydrationInFlight.delete(sessionID) pinnedSessionMessages.delete(sessionID) removedSessionMessages.delete(sessionID) clearSessionStatusTimer(sessionID) @@ -2076,11 +2103,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const run = (async () => { const generation = beginSessionMessageSync(sessionID) const workspace = project.workspace.current() - const [session, messages, todo, diff, statuses] = await Promise.all([ + const [session, messages, todo, statuses] = await Promise.all([ sdk.client.session.get({ sessionID }, { throwOnError: true }), fetchSessionMessageWindow(sessionID), sdk.client.session.todo({ sessionID }), - sdk.client.session.diff({ sessionID }), sdk.client.session.status({ workspace }), ]) if (!isCurrentSessionMessageSync(sessionID, generation)) return @@ -2110,9 +2136,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ hasMoreNewer: false, keepLoadedHistory, }) - setStore("session_diff", sessionID, reconcile(diff.data ?? [])) replaceSessionStatuses(statuses.data ?? {}) }) + const status = statuses.data?.[sessionID] + if (!status || status.type === "idle") hydrateSessionDiff(sessionID) trace.trace("sync-end", { sessionID, loadedIDs: (store.message[sessionID] ?? []).map((message) => message.id), diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 57aacbe5..f5f8b29c 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -37,7 +37,18 @@ import type { ToolFileContent, ToolTextContent, } from "@mendcode/sdk/v2" -import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js" +import { + createEffect, + createMemo, + createResource, + createSignal, + For, + Match, + onCleanup, + onMount, + Show, + Switch, +} from "solid-js" import { useMendTuiProfile } from "@tui/context/mend" import { useCommandDialog } from "@tui/component/dialog-command" import { @@ -58,6 +69,7 @@ import { compactionSummaryPreview, rawReasoningDisplay, reasoningSummary, + reasoningViewportHeight, reasoningViewportMaxHeight, shouldDisplayReasoning, unavailableReasoningLabel, @@ -470,21 +482,23 @@ function CompactionMessage(props: { message: SessionMessageCompaction }) { hasSummaryBody={Boolean(props.message.summary?.trim())} summaryPreview={summaryPreview()} transcriptPreview={transcriptPreview()} - summaryContent={summaryContent() ? ( - - - - ) : undefined} + summaryContent={ + summaryContent() ? ( + + + + ) : undefined + } /> ) } @@ -614,7 +628,12 @@ function AssistantMessage(props: { ) } -function AssistantText(props: { messageID: string; part: SessionMessageAssistantText; syntax: SyntaxStyle; completed: boolean }) { +function AssistantText(props: { + messageID: string + part: SessionMessageAssistantText + syntax: SyntaxStyle + completed: boolean +}) { const { theme } = useTheme() const mend = useMendTuiProfile() const dimensions = useTerminalDimensions() @@ -646,12 +665,19 @@ function AssistantText(props: { messageID: string; part: SessionMessageAssistant const streamingContent = createMemo(() => { if (!streaming()) return "" if (renderer() !== "markdown" && renderer() !== "rich") return "" - return renderStreamingMarkdownTail(source(), richRenderWidth(), { tableMode: "grid", markdownMode: "tables-only" }, { - finalized: false, - output: "text", - }) + return renderStreamingMarkdownTail( + source(), + richRenderWidth(), + { tableMode: "grid", markdownMode: "tables-only" }, + { + finalized: false, + output: "text", + }, + ) }) - const markdownContent = createMemo(() => (renderer() === "markdown" || renderer() === "rich" ? (richStaticContent() ?? richContent() ?? source()) : source())) + const markdownContent = createMemo(() => + renderer() === "markdown" || renderer() === "rich" ? (richStaticContent() ?? richContent() ?? source()) : source(), + ) return ( 0}> {(body) => ( - + @@ -959,13 +988,25 @@ function PresentationToolRow(props: { const pending = createMemo(() => props.state === "pending" || props.state === "running") const errored = createMemo(() => props.state === "error") const event = createMemo(() => - normalizeToolEvent({ tool: props.tool, state: props.state, input: props.input, metadata: props.metadata, output: props.output }), + normalizeToolEvent({ + tool: props.tool, + state: props.state, + input: props.input, + metadata: props.metadata, + output: props.output, + }), ) const wrappedLines = createMemo(() => { const width = Math.max(16, sessionContentWidth(dimensions().width, false) - 12) return event().lines.flatMap((line) => wrapTimelineLine("", line, width)) }) - const icon = createMemo(() => toolPresentationIconForProfile(mend.profile.presentation.profile, props.tool, errored() ? "failure" : event().class)) + const icon = createMemo(() => + toolPresentationIconForProfile( + mend.profile.presentation.profile, + props.tool, + errored() ? "failure" : event().class, + ), + ) const title = createMemo(() => event().title) const cleanDetail = createMemo( () => (props.tool === "webfetch" || props.tool === "websearch") && wrappedLines().length > 0, @@ -1042,7 +1083,13 @@ function PresentationToolRow(props: { ╭─ {(value) => {value()} } {title()} - {(line) => │ {line}} + + {(line) => ( + + │ {line} + + )} + {(result) => ╰─ {result()}} } @@ -1050,9 +1097,18 @@ function PresentationToolRow(props: { {(value) => {value()} } {title()} - {(result) => · {result()}} + + {(result) => · {result()}} + - {(line) => {line}} + + {(line) => ( + + {" "} + {line} + + )} + @@ -1164,7 +1220,9 @@ function ImageGen(props: ToolProps) { return record.status === "error" && typeof record.error === "string" ? record.error : undefined }) const imageWait = createMemo(() => mend.profile.imageGeneration.wait) - const canvas = createMemo(() => imageGenerationCanvasSize(sessionContentWidth(dimensions().width, false), imageWait())) + const canvas = createMemo(() => + imageGenerationCanvasSize(sessionContentWidth(dimensions().width, false), imageWait()), + ) const fieldWidth = createMemo(() => Math.max(8, canvas().width - 2 - imageWait().canvas.paddingX * 2)) const fieldHeight = createMemo(() => Math.max(4, canvas().height - 2 - imageWait().canvas.paddingY * 2 - (imageWait().showMetadata ? 2 : 0)), @@ -1223,7 +1281,12 @@ function ImageGen(props: ToolProps) { return ( - + Image generation failed @@ -1247,9 +1310,7 @@ function ImageGen(props: ToolProps) { {(line) => {line}} {generationModel() ?? "configured image model"} - - size {imageGenMetadataString(metadata(), "requestedSize") ?? "auto"} - + size {imageGenMetadataString(metadata(), "requestedSize") ?? "auto"} @@ -1269,13 +1330,20 @@ function ImageGen(props: ToolProps) { > {summary()} {(value) => Caption: {value()}} - {(value) => Caption unavailable: {value()}} + + {(value) => Caption unavailable: {value()}} + {(value) => ( <> - {value()} + + {value()} + - void openArtifact(value(), "Opened preview")} /> + void openArtifact(value(), "Opened preview")} + /> void openArtifact(path.dirname(value()), "Opened folder")} @@ -1304,7 +1372,12 @@ function GenericTool(props: ToolProps) { + {props.part.name} {input(props.input)} } @@ -1435,9 +1508,7 @@ function InlineTool(props: { - - {(message) => } - + {(message) => } @@ -1491,9 +1562,7 @@ function BlockTool(props: { {props.title.replace(/^# /, "")} {props.children} - - {(message) => } - + {(message) => } ) } @@ -1532,7 +1601,9 @@ function CommandOutput(props: { - {props.output} + + {props.output} + {props.empty} @@ -1587,7 +1658,12 @@ function Bash(props: ToolProps) { - + {command()} @@ -1597,7 +1673,12 @@ function Bash(props: ToolProps) { function Glob(props: ToolProps) { return ( - + Glob "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "} in {normalizePath(stringValue(props.input.path))} @@ -1643,7 +1724,12 @@ function Read(props: ToolProps) { function Grep(props: ToolProps) { return ( - + Grep "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "} in {normalizePath(stringValue(props.input.path))} @@ -1660,14 +1746,21 @@ function Grep(props: ToolProps) { function WebFetch(props: ToolProps) { const { theme } = useTheme() const dimensions = useTerminalDimensions() - const detailLines = createMemo(() => [stringValue(props.input.url)].filter((line): line is string => Boolean(line?.trim()))) + const detailLines = createMemo(() => + [stringValue(props.input.url)].filter((line): line is string => Boolean(line?.trim())), + ) const wrappedLines = createMemo(() => { const width = Math.max(16, sessionContentWidth(dimensions().width, false) - 8) return detailLines().flatMap((line) => wrapTimelineLine("", line, width)) }) return ( <> - + WebFetch @@ -1685,7 +1778,12 @@ function WebFetch(props: ToolProps) { function CodeSearch(props: ToolProps) { return ( - + Exa Code Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} {(results) => <>({results()} results)} @@ -1702,7 +1800,12 @@ function WebSearch(props: ToolProps) { }) return ( <> - + Exa Web Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} {(results) => <>({results()} results)} @@ -1751,7 +1854,12 @@ function Write(props: ToolProps) { - + Write {normalizePath(filePath())} @@ -1764,7 +1872,9 @@ function Edit(props: ToolProps) { const sync = useSync() const filePath = createMemo(() => stringValue(props.input.filePath) ?? "") const diff = createMemo(() => stringValue(props.metadata.diff)) - const title = createMemo(() => `Edited ${normalizePath(filePath())} ${formatDiffStats(diffStatsFromPatch(diff() ?? ""))}`.trim()) + const title = createMemo(() => + `Edited ${normalizePath(filePath())} ${formatDiffStats(diffStatsFromPatch(diff() ?? ""))}`.trim(), + ) const loadFullDiff = () => sync.session .loadFullToolPart(props.sessionID, props.messageID, props.part.id) @@ -1787,7 +1897,12 @@ function Edit(props: ToolProps) { )} - + Edit {normalizePath(filePath())} {input({ replaceAll: props.input.replaceAll })} @@ -1840,7 +1955,12 @@ function ApplyPatch(props: ToolProps) { - + Patch @@ -1897,7 +2017,12 @@ function TodoWrite(props: ToolProps) { - + Updating todos... @@ -1926,7 +2051,12 @@ function Question(props: ToolProps) { - + Asked {questions().length} question{questions().length === 1 ? "" : "s"} @@ -1936,7 +2066,12 @@ function Question(props: ToolProps) { function Skill(props: ToolProps) { return ( - + Skill "{stringValue(props.input.name) ?? pendingInput(props.part)}" ) @@ -1963,7 +2098,11 @@ function MemoryGraph(props: ToolProps) { }) const title = createMemo(() => { const data = snapshot() - const action = data?.action ? data.action.replace(/_/g, " ") : typeof props.input.action === "string" ? props.input.action.replace(/_/g, " ") : "graph" + const action = data?.action + ? data.action.replace(/_/g, " ") + : typeof props.input.action === "string" + ? props.input.action.replace(/_/g, " ") + : "graph" const query = data?.query || (typeof props.input.query === "string" ? props.input.query : "") return query ? `Memory graph · ${action} · ${Locale.truncateMiddle(query, 32)}` : `Memory graph · ${action}` }) @@ -1985,11 +2124,12 @@ function MemoryGraph(props: ToolProps) { const data = snapshot() return data ? compactMemoryGraphRows(data, panelWidth() - 2) : [] }) - const openGraph = () => route.navigate({ - type: "memory", - view: "graph", - returnTo: route.data.type === "session" ? { type: "session", sessionID: route.data.sessionID } : { type: "home" }, - }) + const openGraph = () => + route.navigate({ + type: "memory", + view: "graph", + returnTo: route.data.type === "session" ? { type: "session", sessionID: route.data.sessionID } : { type: "home" }, + }) return ( }> @@ -2008,11 +2148,19 @@ function MemoryGraph(props: ToolProps) { - {short(footer(), panelWidth() - 2)} + + {short(footer(), panelWidth() - 2)} + - {(row) => {row}} + {(row) => ( + + {row} + + )} - Open /memory-graph for the full view + + Open /memory-graph for the full view + @@ -2033,14 +2181,16 @@ function workflowPhaseInputList(value: unknown): WorkflowReceiptPhaseInput[] { blocked: numberValue(item.counts.blocked) ?? 0, } : undefined - return [{ - id: stringValue(item.id), - ordinal: numberValue(item.ordinal) ?? index + 1, - name: stringValue(item.name), - state: stringValue(item.state), - taskIDs, - counts, - }] + return [ + { + id: stringValue(item.id), + ordinal: numberValue(item.ordinal) ?? index + 1, + name: stringValue(item.name), + state: stringValue(item.state), + taskIDs, + counts, + }, + ] }) } @@ -2055,7 +2205,7 @@ function Workflow(props: ToolProps) { const [hover, setHover] = createSignal(false) const action = createMemo(() => stringValue(props.input.action) ?? stringValue(props.metadata.action) ?? "workflow") const runID = createMemo(() => stringValue(props.metadata.runID) ?? stringValue(props.input.runID)) - const inputPlan = createMemo(() => isRecord(props.input.plan) ? props.input.plan : undefined) + const inputPlan = createMemo(() => (isRecord(props.input.plan) ? props.input.plan : undefined)) const [snapshot] = createResource( () => `${runID() ?? ""}:${refresh()}`, async () => { @@ -2073,7 +2223,7 @@ function Workflow(props: ToolProps) { } }, ) - const record = (value: unknown) => isRecord(value) ? value : undefined + const record = (value: unknown) => (isRecord(value) ? value : undefined) const live = createMemo | undefined>(() => record(snapshot.latest)) const liveRun = createMemo | undefined>(() => record(live()?.run)) const liveDefinition = createMemo | undefined>(() => record(live()?.definition)) @@ -2086,21 +2236,24 @@ function Workflow(props: ToolProps) { plan: workflowPhaseInputList(inputPlan()?.phases), }), ) - const state = createMemo(() => - stringValue(liveRun()?.state) - ?? stringValue(props.metadata.state) - ?? (props.part.state.status === "running" ? "working" : action() === "preview" ? "ready" : props.part.state.status), + const state = createMemo( + () => + stringValue(liveRun()?.state) ?? + stringValue(props.metadata.state) ?? + (props.part.state.status === "running" ? "working" : action() === "preview" ? "ready" : props.part.state.status), ) - const title = createMemo(() => - stringValue(liveDefinition()?.name) - ?? stringValue(inputPlan()?.name) - ?? stringValue(props.input.name) - ?? "Workflow", + const title = createMemo( + () => + stringValue(liveDefinition()?.name) ?? + stringValue(inputPlan()?.name) ?? + stringValue(props.input.name) ?? + "Workflow", ) - const objective = createMemo(() => - stringValue(livePlan()?.objective) - ?? stringValue(props.metadata.objective) - ?? stringValue(inputPlan()?.objective), + const objective = createMemo( + () => + stringValue(livePlan()?.objective) ?? + stringValue(props.metadata.objective) ?? + stringValue(inputPlan()?.objective), ) const phaseDiagram = createMemo(() => workflowReceiptPhaseDiagram({ phases: phases() }, activityFrame(), 8)) const panelWidth = createMemo(() => Math.max(52, Math.min(92, dimensions().width - 12))) @@ -2161,19 +2314,43 @@ function Workflow(props: ToolProps) { onMouseOut={() => setHover(false)} > - ◇ {Locale.truncateMiddle(title(), Math.max(18, panelWidth() - 34))} + + ◇ {Locale.truncateMiddle(title(), Math.max(18, panelWidth() - 34))} + - {workflowReceiptStateMarker(state(), activityFrame())} {workflowReceiptStateLabel(state())} + + {workflowReceiptStateMarker(state(), activityFrame())} {workflowReceiptStateLabel(state())} + - action {action()} - run {Locale.truncateMiddle(runID() ?? "not started", Math.max(18, panelWidth() - 20))} - phases {completedPhases()}/{phases().length} complete - {(value) => {Locale.truncate(value(), Math.max(24, panelWidth() - 6))}} + + action {action()} + + + run{" "} + + {Locale.truncateMiddle(runID() ?? "not started", Math.max(18, panelWidth() - 20))} + + + + phases{" "} + + {completedPhases()}/{phases().length} complete + + + + {(value) => ( + + {Locale.truncate(value(), Math.max(24, panelWidth() - 6))} + + )} + 0}> - PHASE FLOW + + PHASE FLOW + {(row) => ( undefined) as Promise< | { - - workflow?: { - id?: string - name?: string - objective?: string - state?: string - phase?: string - rootSessionID?: string - nextWakeup?: number - policy?: { maxTurns?: number } - metrics?: { turns?: number; failures?: number } - time?: { created?: number; updated?: number; activated?: number } + workflow?: { + id?: string + name?: string + objective?: string + state?: string + phase?: string + rootSessionID?: string + nextWakeup?: number + policy?: { maxTurns?: number } + metrics?: { turns?: number; failures?: number } + time?: { created?: number; updated?: number; activated?: number } + } + runs?: unknown[] + events?: unknown[] } - runs?: unknown[] - events?: unknown[] - } | undefined > } catch { @@ -2272,17 +2448,14 @@ function Loop(props: ToolProps) { } } - const [snapshot] = createResource( - () => `${workflowID() || ""}:${refresh()}`, - fetchLoopSnapshot, - ) + const [snapshot] = createResource(() => `${workflowID() || ""}:${refresh()}`, fetchLoopSnapshot) onMount(() => { const clock = setInterval(() => setTick(Date.now()), 1_000) const poll = setInterval(() => setRefresh((value) => value + 1), 2_000) const unsubscribe = sdk.event.on("event", (evt) => { const type = evt.payload?.type as string | undefined - const id = ((evt.payload as { properties?: Record } | undefined)?.properties)?.workflowID + const id = (evt.payload as { properties?: Record } | undefined)?.properties?.workflowID if (type?.startsWith("loop.") && (!workflowID() || id === workflowID())) setRefresh((value) => value + 1) }) onCleanup(() => { @@ -2293,7 +2466,9 @@ function Loop(props: ToolProps) { }) const workflow = createMemo(() => snapshot.latest?.workflow) - const liveState = createMemo(() => workflow()?.state ?? state() ?? (props.part.state.status === "running" ? "working" : "unknown")) + const liveState = createMemo( + () => workflow()?.state ?? state() ?? (props.part.state.status === "running" ? "working" : "unknown"), + ) const livePhase = createMemo(() => workflow()?.phase ?? phase() ?? action()) const liveRootSessionID = createMemo(() => workflow()?.rootSessionID ?? rootSessionID()) const liveCreatedAt = createMemo(() => workflow()?.time?.activated ?? workflow()?.time?.created) @@ -2316,7 +2491,8 @@ function Loop(props: ToolProps) { const [hover, setHover] = createSignal(false) const panelWidth = createMemo(() => Math.max(52, Math.min(88, dimensions().width - 12))) const valueWidth = createMemo(() => Math.max(18, panelWidth() - 20)) - const compact = (value: string, width = valueWidth()) => Locale.truncateMiddle(value.replace(/\s+/g, " ").trim(), width) + const compact = (value: string, width = valueWidth()) => + Locale.truncateMiddle(value.replace(/\s+/g, " ").trim(), width) const stateColor = createMemo(() => { const state = liveState() if (state === "working" || state === "active" || state === "sleeping") return theme.primary @@ -2358,16 +2534,18 @@ function Loop(props: ToolProps) { .join(" · ") }) const listRows = createMemo(() => - workflowList().slice(0, 8).map((item) => ({ - name: item.name ?? item.workflowID, - state: `${item.state} / ${item.phase}`, - progress: - typeof item.turns === "number" && typeof item.maxTurns === "number" - ? `${item.turns}/${item.maxTurns}` - : typeof item.turns === "number" - ? `${item.turns}/unbounded` - : "unbounded", - })), + workflowList() + .slice(0, 8) + .map((item) => ({ + name: item.name ?? item.workflowID, + state: `${item.state} / ${item.phase}`, + progress: + typeof item.turns === "number" && typeof item.maxTurns === "number" + ? `${item.turns}/${item.maxTurns}` + : typeof item.turns === "number" + ? `${item.turns}/unbounded` + : "unbounded", + })), ) const openFirstLoop = () => { const sessionID = liveRootSessionID() ?? workflowList().find((item) => item.rootSessionID)?.rootSessionID @@ -2398,21 +2576,33 @@ function Loop(props: ToolProps) { onMouseOut={() => setHover(false)} > - ↻ Loop Workflows + + ↻ Loop Workflows + {workflowList().length} total - 0} fallback={No loop workflows found.}> + 0} + fallback={No loop workflows found.} + > {listGroups()} {(item) => ( - {compact(item.name, Math.max(18, panelWidth() - 34))} + + {compact(item.name, Math.max(18, panelWidth() - 34))} + - {item.state} - {item.progress} + + {item.state} + + + {" "} + {item.progress} + )} @@ -2457,7 +2647,9 @@ function Loop(props: ToolProps) { onMouseOut={() => setHover(false)} > - ↻ {compact(workflow()?.name ?? "Loop Workflow", Math.max(18, panelWidth() - 36))} + + ↻ {compact(workflow()?.name ?? "Loop Workflow", Math.max(18, panelWidth() - 36))} + {liveLabel()} @@ -2465,8 +2657,12 @@ function Loop(props: ToolProps) { {(row) => ( - {row.label.padEnd(9)} - {compact(row.value)} + + {row.label.padEnd(9)} + + + {compact(row.value)} + )} diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index c59a1da9..05402074 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -149,7 +149,11 @@ import { workflowReceiptStateMarker, workflowReceiptUsage, } from "@tui/util/workflow-receipt" -import { workflowParentSessionID, workflowTaskSessionContext, workflowTaskSiblingSessionID } from "@tui/util/workflow-view" +import { + workflowParentSessionID, + workflowTaskSessionContext, + workflowTaskSiblingSessionID, +} from "@tui/util/workflow-view" import { getScrollAcceleration, isScrollboxAtBottom, @@ -204,6 +208,7 @@ import { compactionSummaryPreview, rawReasoningDisplay, reasoningSummary, + reasoningViewportHeight, reasoningViewportMaxHeight, shouldDisplayReasoning, unavailableReasoningLabel, @@ -226,10 +231,7 @@ import { import { readMendTuiCustomization, resolveMendSessionAccent } from "@/mend/tui/customization" import { formatDuration } from "@/util/format" import { readPermissionsConfig, writePermissionsConfig, type PermissionMode } from "@/mend/config/permissions" -import { - reviewPermissionRequestWithModel, - shouldReviewSmartApproval, -} from "@/mend/permission/smart-approval" +import { reviewPermissionRequestWithModel, shouldReviewSmartApproval } from "@/mend/permission/smart-approval" import { readActiveTuiProfile, writeActiveTuiProfile } from "@/mend/tui/profile-actions" import { memoryToolPresentation, @@ -432,10 +434,7 @@ export function sessionPinnedUserMessageID(input: { return activeParentID && visible(activeParentID) ? activeParentID : undefined } -export function sessionStickyUserEligible(input: { - role: string - parts?: ReadonlyArray<{ type?: string }> -}) { +export function sessionStickyUserEligible(input: { role: string; parts?: ReadonlyArray<{ type?: string }> }) { return input.role === "user" && input.parts !== undefined && !input.parts.some((part) => part.type === "compaction") } @@ -536,7 +535,9 @@ export function sessionBottomFollowMode(input: { return "follow" } -export const SESSION_BOTTOM_FOLLOW_REFLOW_DELAYS_MS = [0, 16, 50, 120, 240, 480, 960, 1_600, 2_400, 3_600, 5_200] as const +export const SESSION_BOTTOM_FOLLOW_REFLOW_DELAYS_MS = [ + 0, 16, 50, 120, 240, 480, 960, 1_600, 2_400, 3_600, 5_200, +] as const export function shouldKeepSessionBottomFollow(input: { following: boolean @@ -559,12 +560,7 @@ export function shouldHandleGlobalSessionInterrupt(input: { activeTurn: boolean pendingInput?: boolean }) { - return ( - input.eventName === "escape" && - !input.defaultPrevented && - input.activeTurn && - !input.pendingInput - ) + return input.eventName === "escape" && !input.defaultPrevented && input.activeTurn && !input.pendingInput } const context = createContext<{ @@ -926,7 +922,8 @@ export function Session() { const parts = sync.data.part[message.id] ?? [] latest ??= parts.findLast((candidate) => candidate.type === "tool" && candidate.tool === "todowrite")?.id latestCompleted ??= parts.findLast( - (candidate) => candidate.type === "tool" && candidate.tool === "todowrite" && candidate.state.status === "completed", + (candidate) => + candidate.type === "tool" && candidate.tool === "todowrite" && candidate.state.status === "completed", )?.id if (latest && latestCompleted) break } @@ -1039,30 +1036,29 @@ export function Session() { if (typeof directory === "string" && directory.length > 0) return directory return typeof process.cwd === "function" ? process.cwd() : "" }) - const [topStatsTick, setTopStatsTick] = createSignal(0) - onMount(() => { - const timer = setInterval(() => setTopStatsTick((tick) => tick + 1), 1500) - onCleanup(() => clearInterval(timer)) - }) const topPathLabel = createMemo(() => { const directory = sessionDirectory() return directory ? directory.replace(Global.Path.home, "~") : "" }) - const [topStats] = createResource( - () => ({ directory: sessionDirectory(), tick: topStatsTick() }), - async ({ directory }) => { - if (!directory) return { added: undefined, removed: undefined, files: undefined, branch: "" } - const diff = await Process.text(["git", "diff", "--numstat", "HEAD", "--"], { cwd: directory, nothrow: true }) - const branch = await Process.text(["git", "branch", "--show-current"], { cwd: directory, nothrow: true }) - const lineStats = diff.code === 0 ? parseGitNumstat(diff.text) : undefined - return { - added: lineStats?.added, - removed: lineStats?.removed, - files: lineStats?.files, - branch: branch.code === 0 ? branch.text.trim() : "", - } - }, - ) + const [topStats, { refetch: refetchTopStats }] = createResource(sessionDirectory, async (directory) => { + if (!directory) return { added: undefined, removed: undefined, files: undefined, branch: "" } + const diff = await Process.text(["git", "diff", "--numstat", "HEAD", "--"], { cwd: directory, nothrow: true }) + const branch = await Process.text(["git", "branch", "--show-current"], { cwd: directory, nothrow: true }) + const lineStats = diff.code === 0 ? parseGitNumstat(diff.text) : undefined + return { + added: lineStats?.added, + removed: lineStats?.removed, + files: lineStats?.files, + branch: branch.code === 0 ? branch.text.trim() : "", + } + }) + onMount(() => { + const timer = setInterval(() => { + if (topStats.loading) return + void refetchTopStats() + }, 15_000) + onCleanup(() => clearInterval(timer)) + }) const topDiffStats = createMemo(() => { const stats = topStats() if (!stats || (!stats.files && !stats.added && !stats.removed)) return @@ -1102,9 +1098,10 @@ export function Session() { const sessionTopNavLabel = createMemo(() => { const workflowTask = currentWorkflowTask() if (workflowTask) { - const cycle = workflowTask.sessionIDs.length > 1 - ? ` ← Prev task ${keybind.print("session_child_cycle_reverse")} → Next task ${keybind.print("session_child_cycle")}` - : "" + const cycle = + workflowTask.sessionIDs.length > 1 + ? ` ← Prev task ${keybind.print("session_child_cycle_reverse")} → Next task ${keybind.print("session_child_cycle")}` + : "" return `↑ Workflow ${keybind.print("session_parent")}${cycle}` } if (session()?.parentID) { @@ -1114,7 +1111,9 @@ export function Session() { const parent = currentLoopWorkflow()?.ownerSessionID ? "Parent" : "Agent View" return `↖ ${parent} ${keybind.print("session_parent")}` }) - const topbarNavVisible = createMemo(() => Boolean(currentWorkflowTask() || session()?.parentID || currentLoopWorkflow())) + const topbarNavVisible = createMemo(() => + Boolean(currentWorkflowTask() || session()?.parentID || currentLoopWorkflow()), + ) const topbarNavWidth = createMemo(() => (topbarNavVisible() ? Bun.stringWidth(sessionTopNavLabel()) : 0)) const headerTitleVisible = createMemo(() => tuiCustomization().sessionTitle) const headerTitleJustify = createMemo(() => sessionHeaderTitleJustify("center")) @@ -1445,16 +1444,16 @@ export function Session() { const result = await sdk.client.session.get({ sessionID }, { throwOnError: true }) return result.data?.permission }, - write: async () => { - const result = await sdk.client.session.get({ sessionID }, { throwOnError: true }) - await sdk.client.session.update( - { - sessionID, - permission: Permission.withSessionMode(result.data?.permission ?? [], mode), - }, - { throwOnError: true }, - ) - }, + write: async () => { + const result = await sdk.client.session.get({ sessionID }, { throwOnError: true }) + await sdk.client.session.update( + { + sessionID, + permission: Permission.withSessionMode(result.data?.permission ?? [], mode), + }, + { throwOnError: true }, + ) + }, }), ) .then((synced) => { @@ -2338,10 +2337,7 @@ export function Session() { ) return if (!scroll || scroll.isDestroyed) return - if ( - anchor && - (boundary === "top" ? !isScrollboxAtTop(scroll, 1) : !isScrollboxAtBottom(scroll, 1)) - ) { + if (anchor && (boundary === "top" ? !isScrollboxAtTop(scroll, 1) : !isScrollboxAtBottom(scroll, 1))) { // The user or the scrollbox already moved away from the paging edge. // Do not resurrect an old anchor into the virtual window: that would // mount rows around the old page while the physical viewport is at a @@ -2438,7 +2434,7 @@ export function Session() { const syncScrollFollowMode = () => { if (!scroll || scroll.isDestroyed) return - const heldAnchor = followSessionOutput() ? undefined : scrollAnchor ?? captureScrollAnchor() + const heldAnchor = followSessionOutput() ? undefined : (scrollAnchor ?? captureScrollAnchor()) const scrollTop = scroll.scrollTop const scrollHeight = scroll.scrollHeight const viewportHeight = scroll.viewport.height @@ -2757,8 +2753,7 @@ export function Session() { const assistant = lastAssistant() const hasActiveTool = assistant ? (sync.data.part[assistant.id] ?? []).some( - (part) => - part.type === "tool" && (part.state.status === "pending" || part.state.status === "running"), + (part) => part.type === "tool" && (part.state.status === "pending" || part.state.status === "running"), ) : false return [ @@ -3229,13 +3224,13 @@ export function Session() { on( () => [ - route.sessionID, - sessionScrollTop(), - followSessionOutput(), - pinnedTurnUserMessageID(), - stickyUserMessageID(), - transcriptRenderKey(), - ] as const, + route.sessionID, + sessionScrollTop(), + followSessionOutput(), + pinnedTurnUserMessageID(), + stickyUserMessageID(), + transcriptRenderKey(), + ] as const, () => trace.trace("render-scroll-state", renderSnapshot(false)), { defer: true }, ), @@ -3393,11 +3388,12 @@ export function Session() { } navigate({ type: "session", - sessionID: workflowParentSessionID({ - sessionID: currentSessionID, - parentSessionID, - workflows: response?.data ?? [], - }) ?? parentSessionID, + sessionID: + workflowParentSessionID({ + sessionID: currentSessionID, + parentSessionID, + workflows: response?.data ?? [], + }) ?? parentSessionID, }) dialog.clear() } @@ -4428,7 +4424,11 @@ export function Session() { 1 : !!session()?.parentID} + canCycle={ + currentWorkflowTask() + ? (currentWorkflowTask()?.sessionIDs.length ?? 0) > 1 + : !!session()?.parentID + } hasParent={!!currentLoopWorkflow()?.ownerSessionID} width={topbarLayout().navWidth} /> @@ -4534,9 +4534,7 @@ export function Session() { onMouseUp={() => openSessionHistory(visibleMessageIDs().at(0))} > ↑ Earlier session history - - {keybind.print("session_history")} or /history - + {keybind.print("session_history")} or /history @@ -4547,150 +4545,150 @@ export function Session() { {(messageID, index) => { return ( - {(message) => { - return ( - - - {(function () { - const command = useCommandDialog() - const [hover, setHover] = createSignal(false) - const dialog = useDialog() - - const handleUnrevert = async () => { - const confirmed = await DialogConfirm.show( - dialog, - "Confirm Redo", - "Are you sure you want to restore the reverted messages?", - ) - if (confirmed) { - command.trigger("session.redo") - } - } - + {(message) => { return ( - setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={handleUnrevert} - marginTop={1} - flexShrink={0} - border={["left"]} - customBorderChars={SplitBorder.customBorderChars} - borderColor={theme.backgroundPanel} - > - - + + + {(function () { + const command = useCommandDialog() + const [hover, setHover] = createSignal(false) + const dialog = useDialog() + + const handleUnrevert = async () => { + const confirmed = await DialogConfirm.show( + dialog, + "Confirm Redo", + "Are you sure you want to restore the reverted messages?", + ) + if (confirmed) { + command.trigger("session.redo") + } + } + + return ( + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={handleUnrevert} + marginTop={1} + flexShrink={0} + border={["left"]} + customBorderChars={SplitBorder.customBorderChars} + borderColor={theme.backgroundPanel} + > + + {revert()!.reverted.length} message reverted {keybind.print("messages_redo")}{" "} or /redo to restore - - - - - {(file) => ( - - {file.filename} - 0}> - +{file.additions} - - 0}> + + + + + {(file) => ( + + {file.filename} + 0}> + +{file.additions} + + 0}> {" "} -{file.deletions} - - - )} - - - - - + + + )} + + + + + + ) + })()} + + = revert()!.messageID}> + <> + + + void sendQueuedNow(message().id)} + sendNowPending={sendNowMessageID() === message().id} + simpleHistory={false} + compactSubagentPrompt={Boolean(session()?.parentID)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + dialog.replace(() => ( + prompt?.set(promptInfo)} + onEditPrompt={editQueuedPrompt} + /> + )) + }} + /> + + + { + if (renderer.getSelection()?.getSelectedText()) return + dialog.replace(() => ( + prompt?.set(promptInfo)} + /> + )) + }} + message={message() as UserMessage} + parts={sync.data.part[message().id] ?? []} + onSendNow={() => void sendQueuedNow(message().id)} + sendNowPending={sendNowMessageID() === message().id} + simpleHistory={ + !showCompactedToolCalls() && + shouldUseSimpleSessionHistory({ + messageID: message().id, + fullStartID: fullHistoryStartID(), + }) + } + compactSubagentPrompt={Boolean(session()?.parentID)} + /> + + + + + + + ) - })()} - - = revert()!.messageID}> - <> - - - void sendQueuedNow(message().id)} - sendNowPending={sendNowMessageID() === message().id} - simpleHistory={false} - compactSubagentPrompt={Boolean(session()?.parentID)} - onMouseUp={() => { - if (renderer.getSelection()?.getSelectedText()) return - dialog.replace(() => ( - prompt?.set(promptInfo)} - onEditPrompt={editQueuedPrompt} - /> - )) - }} - /> - - - { - if (renderer.getSelection()?.getSelectedText()) return - dialog.replace(() => ( - prompt?.set(promptInfo)} - /> - )) - }} - message={message() as UserMessage} - parts={sync.data.part[message().id] ?? []} - onSendNow={() => void sendQueuedNow(message().id)} - sendNowPending={sendNowMessageID() === message().id} - simpleHistory={ - !showCompactedToolCalls() && - shouldUseSimpleSessionHistory({ - messageID: message().id, - fullStartID: fullHistoryStartID(), - }) - } - compactSubagentPrompt={Boolean(session()?.parentID)} - /> - - - - - - - - ) - }} + }} ) }} @@ -4761,47 +4759,47 @@ export function Session() { {/* Do not keep the hidden prompt subtree mounted while a question/permission owns input. */} - - { - renderMendEditor({ - sessionID: route.sessionID, - permissionMode: permissionMode(), - permissionModeLabel: permissionModeLabel(), - permissionPending: permissionPendingCount(), - visible: visible(), - disabled: promptDisabled(), - ref: bind, - onSubmit: handlePromptSubmit, - right: , - defaultEditor: () => ( - } - /> - ), - }) as any - } - - + + { + renderMendEditor({ + sessionID: route.sessionID, + permissionMode: permissionMode(), + permissionModeLabel: permissionModeLabel(), + permissionPending: permissionPendingCount(), + visible: visible(), + disabled: promptDisabled(), + ref: bind, + onSubmit: handlePromptSubmit, + right: , + defaultEditor: () => ( + } + /> + ), + }) as any + } + + {(item) => } @@ -4867,20 +4865,34 @@ function SessionTopMetrics(props: { ) } -function SessionTopNav(props: { mode: "subagent" | "workflow-task" | "loop"; canCycle?: boolean; hasParent?: boolean; width: number }) { +function SessionTopNav(props: { + mode: "subagent" | "workflow-task" | "loop" + canCycle?: boolean + hasParent?: boolean + width: number +}) { const { theme } = useTheme() const keybind = useKeybind() const command = useCommandDialog() const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) - const items = createMemo<(SessionTopbarNavItem & { - id: "parent" | "prev" | "next" - commandID: "session.parent" | "session.child.previous" | "session.child.next" - })[]>(() => { + const items = createMemo< + (SessionTopbarNavItem & { + id: "parent" | "prev" | "next" + commandID: "session.parent" | "session.child.previous" | "session.child.next" + })[] + >(() => { const all = [ { id: "parent" as const, icon: props.mode === "workflow-task" ? "↑" : "↖", - label: props.mode === "workflow-task" ? "Workflow" : props.mode === "loop" ? (props.hasParent ? "Parent" : "Agent View") : "Parent", + label: + props.mode === "workflow-task" + ? "Workflow" + : props.mode === "loop" + ? props.hasParent + ? "Parent" + : "Agent View" + : "Parent", key: keybind.print("session_parent"), commandID: "session.parent" as const, }, @@ -5061,7 +5073,8 @@ function sessionLiveStateLabel(input: { input.status?.type === "retry" || Boolean(lastAssistant && !lastAssistant.time.completed), }) - ) return SESSION_AGENT_STATE_UNKNOWN_MESSAGE + ) + return SESSION_AGENT_STATE_UNKNOWN_MESSAGE if (lastUser && (!lastAssistant || lastAssistant.time.created < lastUser.time.created)) return "waiting" if (lastAssistant) return "responded" return "ready" @@ -5756,19 +5769,19 @@ function CompactionCard(props: { transcriptPreview={transcriptPreview()} summaryContent={ summaryContent() ? ( - - - + + + ) : undefined } scratchpad={ @@ -5852,6 +5865,17 @@ function UserMessage(props: { const queuedFg = createMemo(() => selectedForeground(theme, color())) const sendNowBackground = createMemo(() => (props.sendNowPending ? theme.backgroundElement : theme.accent)) const sendNowForeground = createMemo(() => selectedForeground(theme, sendNowBackground())) + const peerMessage = createMemo(() => { + const part = props.parts.find( + (item) => item.type === "text" && (item.metadata as Record | undefined)?.kind === "peer_message", + ) + if (!part || part.type !== "text") return undefined + const metadata = part.metadata as Record | undefined + return { + sourceTitle: typeof metadata?.sourceTitle === "string" ? metadata.sourceTitle : undefined, + sourceSessionID: typeof metadata?.sourceSessionID === "string" ? metadata.sourceSessionID : undefined, + } + }) const subagentInitialPrompt = createMemo(() => props.compactSubagentPrompt && props.index === 0) const collapsedDisplayText = createMemo(() => userMessageDisplayText( @@ -5863,7 +5887,10 @@ function UserMessage(props: { : undefined, ), ) - const effectiveExpandedText = createMemo(() => !props.sticky && !props.simpleHistory && expandedText()) + // Keep the expanded view available for compacted history and the sticky + // header too. The viewport is bounded below, so opening a long message does + // not make the transcript grow without limit or silently discard its tail. + const effectiveExpandedText = createMemo(() => expandedText()) const expandedViewportRows = createMemo(() => Math.max(6, Math.min(18, Math.floor(dimensions().height * 0.3)))) const expandedLines = createMemo(() => fullText().split("\n")) const expandedMaxOffset = createMemo(() => Math.max(0, expandedLines().length - expandedViewportRows())) @@ -5960,9 +5987,9 @@ function UserMessage(props: { ) const latestCompactionMessageID = createMemo( () => - (sync.data.message[props.message.sessionID] ?? []) - .filter((message) => message.role === "user") - .findLast((message) => (sync.data.part[message.id] ?? []).some((part) => part.type === "compaction"))?.id, + (sync.data.message[props.message.sessionID] ?? []) + .filter((message) => message.role === "user") + .findLast((message) => (sync.data.part[message.id] ?? []).some((part) => part.type === "compaction"))?.id, ) const showCompactionCard = createMemo(() => props.message.id === latestCompactionMessageID()) const visibleCompaction = createMemo(() => (showCompactionCard() ? compaction() : undefined)) @@ -6020,7 +6047,12 @@ function UserMessage(props: { - ● {subagentInitialPrompt() ? "Subagent prompt" : "You"} + ●{" "} + {peerMessage() + ? `Peer · ${peerMessage()!.sourceTitle ?? peerMessage()!.sourceSessionID ?? "session"}` + : subagentInitialPrompt() + ? "Subagent prompt" + : "You"} · {queuedPromptWaitLabel(sync.data.config.queue?.mode)} @@ -6122,11 +6154,7 @@ function UserMessage(props: { - + - + {(body) => ( - + @@ -7075,17 +7103,9 @@ function ImageGen(props: ToolProps) { ) return imageGenerationCanvasSize(available, imageWait()) }) - const activityFieldWidth = createMemo(() => - Math.max(8, activityCanvas().width - 2 - imageWait().canvas.paddingX * 2), - ) + const activityFieldWidth = createMemo(() => Math.max(8, activityCanvas().width - 2 - imageWait().canvas.paddingX * 2)) const activityFieldHeight = createMemo(() => - Math.max( - 4, - activityCanvas().height - - 2 - - imageWait().canvas.paddingY * 2 - - (imageWait().showMetadata ? 2 : 0), - ), + Math.max(4, activityCanvas().height - 2 - imageWait().canvas.paddingY * 2 - (imageWait().showMetadata ? 2 : 0)), ) const activityFrameCount = createMemo(() => imageGenerationWaitFrameCount(imageWait())) const activityLines = createMemo(() => @@ -7180,9 +7200,7 @@ function ImageGen(props: ToolProps) { {(line) => {line}} {generationModel() ?? "configured image model"} - - size {imageGenMetadataString(metadata(), "requestedSize") ?? "auto"} - + size {imageGenMetadataString(metadata(), "requestedSize") ?? "auto"} @@ -7209,7 +7227,9 @@ function ImageGen(props: ToolProps) { > {summary()} {(value) => Caption: {value()}} - {(value) => Caption unavailable: {value()}} + + {(value) => Caption unavailable: {value()}} + {(value) => ( <> @@ -7217,7 +7237,10 @@ function ImageGen(props: ToolProps) { {value()} - void openArtifact(value(), "Opened preview")} /> + void openArtifact(value(), "Opened preview")} + /> void openArtifact(path.dirname(value()), "Opened folder")} @@ -8672,10 +8695,10 @@ function MemoryGraph(props: ToolProps) { }) const openGraph = () => route.navigate({ - type: "memory", - view: "graph", - returnTo: route.data.type === "session" ? { type: "session", sessionID: route.data.sessionID } : { type: "home" }, - }) + type: "memory", + view: "graph", + returnTo: route.data.type === "session" ? { type: "session", sessionID: route.data.sessionID } : { type: "home" }, + }) return ( }> @@ -8732,7 +8755,13 @@ function workflowToolReceipt(snapshot: WorkflowSnapshot) { createdAt: workflowToolNumber(snapshot.run.createdAt), updatedAt: workflowToolNumber(snapshot.run.updatedAt), }, - phases: snapshot.phases.map((phase) => ({ state: phase.state, counts: phase.counts, id: phase.id, ordinal: phase.ordinal, name: phase.name })), + phases: snapshot.phases.map((phase) => ({ + state: phase.state, + counts: phase.counts, + id: phase.id, + ordinal: phase.ordinal, + name: phase.name, + })), tasks: snapshot.tasks.map((task) => ({ id: task.id, name: task.name, @@ -8741,7 +8770,11 @@ function workflowToolReceipt(snapshot: WorkflowSnapshot) { blocker: task.blocker, attempt: task.attempt, })), - events: snapshot.events.map((event) => ({ type: event.type, summary: event.summary, createdAt: workflowToolNumber(event.createdAt) })), + events: snapshot.events.map((event) => ({ + type: event.type, + summary: event.summary, + createdAt: workflowToolNumber(event.createdAt), + })), usage: snapshot.usage ? { inputTokens: workflowToolNumber(snapshot.usage.inputTokens), @@ -8765,12 +8798,15 @@ function Workflow(props: ToolProps) { const [activityFrame, setActivityFrame] = createSignal(0) const [hover, setHover] = createSignal(false) const runID = createMemo(() => (typeof props.metadata.runID === "string" ? props.metadata.runID : undefined)) - const [snapshot] = createResource(() => `${runID() ?? ""}:${refresh()}`, async () => { - const id = runID() - if (!id) return undefined - const response = await sdk.client.workflow.show({ runID: id }).catch(() => undefined) - return response?.data - }) + const [snapshot] = createResource( + () => `${runID() ?? ""}:${refresh()}`, + async () => { + const id = runID() + if (!id) return undefined + const response = await sdk.client.workflow.show({ runID: id }).catch(() => undefined) + return response?.data + }, + ) const live = createMemo(() => snapshot.latest ?? snapshot()) const receipt = createMemo(() => { const current = live() @@ -8784,9 +8820,25 @@ function Workflow(props: ToolProps) { }), ) const phaseDiagram = createMemo(() => workflowReceiptPhaseDiagram({ phases: phases() }, activityFrame(), 8)) - const state = createMemo(() => live()?.run.state ?? props.metadata.state ?? (props.part.state.status === "running" ? "working" : props.part.state.status)) - const title = createMemo(() => live()?.definition.name || props.input.plan?.name || (typeof props.input.name === "string" ? props.input.name : "Workflow")) - const objective = createMemo(() => live()?.revision.plan.objective || props.metadata.objective || props.input.plan?.objective || "Declarative workflow execution") + const state = createMemo( + () => + live()?.run.state ?? + props.metadata.state ?? + (props.part.state.status === "running" ? "working" : props.part.state.status), + ) + const title = createMemo( + () => + live()?.definition.name || + props.input.plan?.name || + (typeof props.input.name === "string" ? props.input.name : "Workflow"), + ) + const objective = createMemo( + () => + live()?.revision.plan.objective || + props.metadata.objective || + props.input.plan?.objective || + "Declarative workflow execution", + ) const panelWidth = createMemo(() => Math.max(52, Math.min(92, dimensions().width - 12))) const rows = createMemo(() => { const current = receipt() @@ -8807,11 +8859,16 @@ function Workflow(props: ToolProps) { ["tasks", workflowReceiptProgress(current)], ["active", `${counts.working} working · ${counts.queued} queued`], ["elapsed", `${Math.round(workflowReceiptElapsed(current) / 1000)}s`], - ["model", model ? `${model.providerID}/${model.modelID}${model.variant ? `#${model.variant}` : ""}` : "task route"], + [ + "model", + model ? `${model.providerID}/${model.modelID}${model.variant ? `#${model.variant}` : ""}` : "task route", + ], ["usage", workflowReceiptUsage(current)], ] as Array<[string, string]> }) - const nextAction = createMemo(() => receipt() ? workflowReceiptNextAction(receipt()!) : "Open the workflow monitor for the latest snapshot.") + const nextAction = createMemo(() => + receipt() ? workflowReceiptNextAction(receipt()!) : "Open the workflow monitor for the latest snapshot.", + ) onMount(() => { const animation = setInterval(() => { @@ -8865,7 +8922,13 @@ function Workflow(props: ToolProps) { ) { onMouseOut={() => setHover(false)} > - {toolPresentationIcon("workflow")} {Locale.truncateMiddle(title(), Math.max(18, panelWidth() - 24))} + + {toolPresentationIcon("workflow")} {Locale.truncateMiddle(title(), Math.max(18, panelWidth() - 24))} + - {workflowReceiptStateMarker(state(), activityFrame())} {workflowReceiptStateLabel(state())} + + {workflowReceiptStateMarker(state(), activityFrame())} {workflowReceiptStateLabel(state())} + {(row) => ( - {row[0].padEnd(10)} - {Locale.truncateMiddle(row[1], Math.max(18, panelWidth() - 24))} + + {row[0].padEnd(10)} + + + {Locale.truncateMiddle(row[1], Math.max(18, panelWidth() - 24))} + )} - {Locale.truncate(objective(), Math.max(24, panelWidth() - 8))} - next: {Locale.truncate(nextAction(), Math.max(24, panelWidth() - 14))} + + {Locale.truncate(objective(), Math.max(24, panelWidth() - 8))} + + + next: {Locale.truncate(nextAction(), Math.max(24, panelWidth() - 14))} + - PHASE FLOW + + PHASE FLOW + {(row) => ( ) { - open monitor + + open monitor + - void control("pause").catch((error) => toast.error(error))}>[pause] - void control("resume").catch((error) => toast.error(error))}> [resume] - void control("stop").catch((error) => toast.error(error))}> [stop] + void control("pause").catch((error) => toast.error(error))}> + [pause] + + void control("resume").catch((error) => toast.error(error))}> + {" "} + [resume] + + void control("stop").catch((error) => toast.error(error))}> + {" "} + [stop] + diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index f13d5f0e..275b21ed 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -1,7 +1,7 @@ import { createStore } from "solid-js/store" import { createMemo, For, Match, Show, Switch } from "solid-js" import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" -import type { TextareaRenderable } from "@opentui/core" +import type { MouseEvent, TextareaRenderable } from "@opentui/core" import { useKeybind } from "../../context/keybind" import { useTheme } from "../../context/theme" import type { PermissionRequest } from "@mendcode/sdk/v2" @@ -22,8 +22,31 @@ import { useTuiConfig } from "../../context/tui-config" type PermissionStage = "permission" | "always" | "reject" -export function permissionPromptHoverSelection(eventType: string, current: T, hovered: T) { - return eventType === "move" ? hovered : current +export type PermissionPromptPointerState = { + keyboardNavigation: boolean + x?: number + y?: number +} + +export function permissionPromptHoverSelection( + event: Pick, + current: T, + hovered: T, + pointer: PermissionPromptPointerState, +) { + // OpenTUI rechecks the hit grid after a render and emits `over` at the + // pointer's parked coordinates. It must never override a keyboard choice. + if (event.type !== "move") return current + + const moved = pointer.x !== event.x || pointer.y !== event.y + pointer.x = event.x + pointer.y = event.y + + // A real pointer move is an explicit mouse intent. A replay at the same + // coordinates is only a layout/hover recheck and keeps keyboard ownership. + if (pointer.keyboardNavigation && !moved) return current + if (moved) pointer.keyboardNavigation = false + return hovered } function normalizePath(input?: string) { @@ -556,6 +579,9 @@ function Prompt>(props: { selected: keys[0], expanded: false, }) + const pointer: PermissionPromptPointerState = { + keyboardNavigation: false, + } const diffKey = Keybind.parse("ctrl+f")[0] const narrow = createMemo(() => dimensions().width < 80) const dialog = useDialog() @@ -565,6 +591,7 @@ function Prompt>(props: { if (evt.name === "left" || evt.name === "up" || evt.name == "h" || evt.name === "k") { evt.preventDefault() + pointer.keyboardNavigation = true const idx = keys.indexOf(store.selected) const next = keys[(idx - 1 + keys.length) % keys.length] setStore("selected", next) @@ -572,6 +599,7 @@ function Prompt>(props: { if (evt.name === "right" || evt.name === "down" || evt.name == "l" || evt.name === "j") { evt.preventDefault() + pointer.keyboardNavigation = true const idx = keys.indexOf(store.selected) const next = keys[(idx + 1) % keys.length] setStore("selected", next) @@ -650,12 +678,21 @@ function Prompt>(props: { paddingRight={2} backgroundColor={option === store.selected ? theme.backgroundMenu : undefined} // OpenTUI emits synthetic `over` events when layout changes. Applying - // selection only to real `move` events keeps keyboard navigation stable. + // selection only to real, changed-coordinate `move` events keeps + // keyboard navigation stable while preserving deliberate mouse use. onMouseMove={(event) => { - setStore("selected", permissionPromptHoverSelection(event.type, store.selected, option)) + setStore("selected", permissionPromptHoverSelection(event, store.selected, option, pointer)) + }} + onMouseDown={(event) => { + pointer.keyboardNavigation = false + pointer.x = event.x + pointer.y = event.y + setStore("selected", option) }} - onMouseDown={() => setStore("selected", option)} - onMouseUp={() => { + onMouseUp={(event) => { + pointer.keyboardNavigation = false + pointer.x = event.x + pointer.y = event.y setStore("selected", option) props.onSelect(option) }} diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/stats/index.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/stats/index.tsx index b94aec34..6515d722 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/stats/index.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/stats/index.tsx @@ -20,6 +20,7 @@ import { useProject } from "@tui/context/project" import { useKV } from "@tui/context/kv" import { useDialog } from "@tui/ui/dialog" import { DialogPrompt } from "@tui/ui/dialog-prompt" +import { DialogSelect } from "@tui/ui/dialog-select" import { Spinner } from "@tui/component/spinner" import { CommandDeck, commandDeckLayout } from "@tui/component/command-deck" import { Locale } from "@/util/locale" @@ -34,11 +35,13 @@ import { type SessionInsightInput, type UsageInsights, } from "@tui/util/usage-insights" +import { loadSubscriptionUsage, type SubscriptionUsageSnapshot } from "@tui/routes/stats/subscription-usage" const DAY_MS = 24 * 60 * 60 * 1000 const DEFAULT_DAYS = 365 const ADVANCED_DAYS = 365 const WEATHER_KV_KEY = "stats_weather" +const WIDGETS_KV_KEY = "stats_widgets_v1" export const STATS_CACHE_KEY = "stats_insights_v2" const STATS_CACHE_STALE_MS = 5 * 60 * 1000 const STATS_TODAY_CACHE_STALE_MS = 5 * 60 * 1000 @@ -53,12 +56,26 @@ const STATS_ADVANCED_MESSAGE_LIMIT = 100 const HEATMAP_ROWS = 7 const HEATMAP_COLUMNS = Math.ceil(DEFAULT_DAYS / HEATMAP_ROWS) const HEAT_MODES: HeatMode[] = ["daily", "weekly", "cumulative"] +const STATS_WIDGET_IDS = [ + "token-mix", + "response-load", + "subscription-usage", + "peak-pressure", + "clock", + "weather", + "status", + "outcome-signals", + "tools", + "agents-models", +] as const +const DEFAULT_STATS_WIDGETS: StatsWidgetID[] = STATS_WIDGET_IDS.filter((id) => id !== "peak-pressure") export function statsUsesScrollableLayout(width: number, height: number) { return width < 92 || height < 42 } type HeatMode = "daily" | "weekly" | "cumulative" +type StatsWidgetID = (typeof STATS_WIDGET_IDS)[number] type StatsScope = "global" | "project" | "directory" type SessionScopeQuery = { scope?: "project"; path?: string; directory?: string } type SessionListQuery = SessionScopeQuery & { start: number; limit: number } @@ -83,6 +100,33 @@ type StatsCachePayload = { data: UsageInsights } +const STATS_WIDGET_META: Record = { + "token-mix": { title: "Token Mix", description: "Input, output, reasoning, and cache", category: "Activity" }, + "response-load": { title: "Response Load", description: "AI and tool runtime", category: "Activity" }, + "subscription-usage": { + title: "Subscription Usage", + description: "Real Codex plan windows from local telemetry", + category: "Activity", + }, + "peak-pressure": { title: "Peak Pressure", description: "Today compared with the peak day", category: "Activity" }, + clock: { title: "Clock", description: "Local time and date", category: "Side rail" }, + weather: { title: "Weather", description: "Configured Open-Meteo weather", category: "Side rail" }, + status: { title: "Status", description: "Cache and loaded-window diagnostics", category: "Side rail" }, + "outcome-signals": { title: "Outcome Signals", description: "Local code-change evidence", category: "Details" }, + tools: { title: "Most Used Tools", description: "Tool call frequency", category: "Details" }, + "agents-models": { title: "Agents & Models", description: "Most active agents and models", category: "Details" }, +} + +export function normalizeStatsWidgets(input: unknown): StatsWidgetID[] { + if (!Array.isArray(input)) return [...DEFAULT_STATS_WIDGETS] + const known = new Set(STATS_WIDGET_IDS) + return [ + ...new Set( + input.filter((item): item is StatsWidgetID => typeof item === "string" && known.has(item as StatsWidgetID)), + ), + ] +} + function sameWeatherConfig(left: StatsWeatherConfig, right: StatsWeatherConfig) { return ( left.enabled === right.enabled && @@ -493,7 +537,7 @@ function LoadingStats(props: { tiny: boolean; error?: string }) { - + · · · · · · · · · · · · · · · · · · · · · @@ -549,10 +593,13 @@ function CompactStats(props: { contentWidth: number selectedDay?: DailyUsage selectedDayIndex?: number - activityLoading?: boolean tokenRows?: Array<{ label: string; value: string; detail?: string }> responseRows?: Array<{ label: string; value: string; detail?: string }> statusRows?: Array<{ label: string; value: string; detail?: string }> + widgets: ReadonlySet + subscriptionUsage?: SubscriptionUsageSnapshot + subscriptionLoading: boolean + subscriptionError?: string tall?: boolean }) { return ( @@ -569,19 +616,32 @@ function CompactStats(props: { rows={7} labels={true} selectedIndex={props.selectedDayIndex} - loading={props.activityLoading} /> - - - - - - - + + + + + + + + + + + + + + + + @@ -598,7 +658,6 @@ function MainDashboard(props: { heatCellWidth: number selectedDay?: DailyUsage selectedDayIndex?: number - activityLoading?: boolean kpis: Array<{ label: string; value: string; detail?: string }> tokenRows: Array<{ label: string; value: string; detail?: string }> responseRows: Array<{ label: string; value: string; detail?: string }> @@ -610,6 +669,10 @@ function MainDashboard(props: { weatherLoading: boolean weatherError?: string onConfigureWeather: () => void + widgets: ReadonlySet + subscriptionUsage?: SubscriptionUsageSnapshot + subscriptionLoading: boolean + subscriptionError?: string }) { const { theme } = useTheme() const peakWidth = createMemo(() => (props.roomy ? 18 : 14)) @@ -633,88 +696,126 @@ function MainDashboard(props: { cellWidth={props.heatCellWidth} labels={true} selectedIndex={props.selectedDayIndex} - loading={props.activityLoading} /> - - - - - - - - - 0 - ? (props.data.days.at(-1)?.tokens ?? 0) / props.data.totals.peakTokens - : 0 - } - width={peakWidth()} - color={theme.success} - /> - - today vs peak day - - - + + + + + + + + + + + + + + + + + + 0 + ? (props.data.days.at(-1)?.tokens ?? 0) / props.data.totals.peakTokens + : 0 + } + width={peakWidth()} + color={theme.success} + /> + + today vs peak day + + + + + - - - - Local evidence only; git/PR metrics pending. - - - - - - No tool calls in this window - - 0}> - ({ - name: item.name, - right: formatInsightNumber(item.count), - }))} - nameWidth={20} - /> - - - - - ({ name: item.name, right: formatInsightNumber(item.count) }))} - nameWidth={22} - /> - ({ - name: item.name, - right: formatInsightNumber(item.tokens), - color: theme.textMuted, - }))} - nameWidth={24} - /> - + + + + + Local evidence only; git/PR metrics pending. + + + + + + + + No tool calls in this window + + 0}> + ({ + name: item.name, + right: formatInsightNumber(item.count), + }))} + nameWidth={20} + /> + + + + + + + ({ name: item.name, right: formatInsightNumber(item.count) }))} + nameWidth={22} + /> + ({ + name: item.name, + right: formatInsightNumber(item.tokens), + color: theme.textMuted, + }))} + nameWidth={24} + /> + + - + - - - + + + + + + + + + @@ -733,6 +834,80 @@ function ProgressBar(props: { value: number; width: number; color?: ThemeColorVa ) } +function resetLabel(timestamp: number | undefined) { + if (!timestamp) return "reset unknown" + const remaining = Math.max(0, timestamp - Date.now()) + if (remaining < 60_000) return "resets soon" + if (remaining < 60 * 60_000) return `resets in ${Math.ceil(remaining / 60_000)}m` + if (remaining < 24 * 60 * 60_000) return `resets in ${Math.ceil(remaining / (60 * 60_000))}h` + return `resets in ${Math.ceil(remaining / (24 * 60 * 60_000))}d` +} + +function SubscriptionUsageWidget(props: { + usage?: SubscriptionUsageSnapshot + loading: boolean + error?: string + width?: number + grow?: boolean +}) { + const { theme } = useTheme() + return ( + + + + Reading Codex limits… + + + {(usage) => ( + + + Codex{usage().plan ? ` · ${usage().plan}` : ""} + + + {(window) => ( + + + {window.label} + = 90 + ? theme.error + : window.usedPercent >= 70 + ? theme.warning + : theme.textMuted + } + > + {Math.round(window.usedPercent)}% · {resetLabel(window.resetsAt)} + + + = 90 + ? theme.error + : window.usedPercent >= 70 + ? theme.warning + : theme.success + } + /> + + )} + + + )} + + + + {props.error ?? "Codex plan limits unavailable"} + Claude, Kimi, Grok · no local quota feed + + + + + ) +} + function timelineLabel(day: DailyUsage | undefined) { if (!day) return "" const date = new Date(day.time) @@ -835,43 +1010,33 @@ function TokenActivityView(props: { rows?: number labels?: boolean selectedIndex?: number - loading?: boolean }) { return ( - - updating token activity… - - } - > - - - - - - - - - - - + + + + + + + + + + ) } @@ -1122,8 +1287,10 @@ export function statsSelectedDayIndex(input: { days: readonly { day: string }[] selectedDay?: string selectedIndex?: number + followLatest?: boolean }) { if (input.days.length <= 0) return undefined + if (input.followLatest) return input.days.length - 1 const selectedDayIndex = input.selectedDay ? input.days.findIndex((day) => day.day === input.selectedDay) : -1 if (selectedDayIndex >= 0) return selectedDayIndex @@ -1187,18 +1354,19 @@ export function Stats() { const [weatherRefresh, setWeatherRefresh] = createSignal(0) const [weatherError, setWeatherError] = createSignal() const [weatherReady, setWeatherReady] = createSignal(false) + const [widgets, setWidgets] = createSignal([...DEFAULT_STATS_WIDGETS]) const [selectedDayIndex, setSelectedDayIndex] = createSignal() const [selectedDayKey, setSelectedDayKey] = createSignal() - const [selectedDayLoading, setSelectedDayLoading] = createSignal(false) + const [selectedDayPinned, setSelectedDayPinned] = createSignal(false) const [cacheUpdated, setCacheUpdated] = createSignal() const [cacheReady, setCacheReady] = createSignal(false) const [insightRefreshRequest, setInsightRefreshRequest] = createSignal(0) const [refreshingInsights, setRefreshingInsights] = createSignal(false) const [insightError, setInsightError] = createSignal() const [statsRefreshTick, setStatsRefreshTick] = createSignal(0) + const [subscriptionRefreshTick, setSubscriptionRefreshTick] = createSignal(0) + const [subscriptionError, setSubscriptionError] = createSignal() let activeInsightRequest: AbortController | undefined - let selectedDayTimer: ReturnType | undefined - let pendingSelectedDay: { index: number; key: string | undefined } | undefined const tiny = createMemo(() => statsUsesScrollableLayout(dimensions().width, dimensions().height)) const narrow = createMemo(() => !tiny() && dimensions().width < 124) const wide = createMemo(() => dimensions().width >= 142 && dimensions().height >= 34) @@ -1232,7 +1400,6 @@ export function Stats() { const statsRefreshPoll = setInterval(() => setStatsRefreshTick((value) => value + 1), STATS_TODAY_CACHE_STALE_MS) onCleanup(() => { clearInterval(statsRefreshPoll) - if (selectedDayTimer) clearTimeout(selectedDayTimer) activeInsightRequest?.abort() }) createEffect(() => { @@ -1252,6 +1419,7 @@ export function Stats() { createEffect(() => { if (!kv.ready) return setWeatherConfig(kv.get(WEATHER_KV_KEY, { enabled: false }) as StatsWeatherConfig) + setWidgets(normalizeStatsWidgets(kv.get(WIDGETS_KV_KEY))) }) const [insights] = createResource( () => ({ @@ -1333,13 +1501,28 @@ export function Stats() { }, ) + const [subscriptionUsage] = createResource( + () => ({ refresh: subscriptionRefreshTick() }), + async () => { + setSubscriptionError(undefined) + try { + return await loadSubscriptionUsage() + } catch (error) { + setSubscriptionError(error instanceof Error ? error.message : "Subscription usage failed") + return undefined + } + }, + ) + const weatherStart = setTimeout(() => setWeatherReady(true), 1_500) const weatherPoll = setInterval(() => { if (weatherConfig().enabled) setWeatherRefresh((value) => value + 1) }, 30 * 60_000) + const subscriptionPoll = setInterval(() => setSubscriptionRefreshTick((value) => value + 1), 60_000) onCleanup(() => { clearTimeout(weatherStart) clearInterval(weatherPoll) + clearInterval(subscriptionPoll) }) async function configureWeather() { @@ -1376,7 +1559,47 @@ export function Stats() { } } + function persistWidgets(next: StatsWidgetID[]) { + const normalized = normalizeStatsWidgets(next) + setWidgets(normalized) + kv.set(WIDGETS_KV_KEY, normalized) + } + + function showWidgetPicker() { + const enabled = new Set(widgets()) + dialog.replace(() => ( + ({ + title: `${enabled.has(id) ? "●" : "○"} ${STATS_WIDGET_META[id].title}`, + value: id, + category: STATS_WIDGET_META[id].category, + description: STATS_WIDGET_META[id].description, + onSelect: () => { + const next = enabled.has(id) ? widgets().filter((item) => item !== id) : [...widgets(), id] + persistWidgets(next) + showWidgetPicker() + }, + })), + { + title: "Reset default layout", + value: "__reset__", + category: "Layout", + description: "Restore the recommended widget set", + onSelect: () => { + persistWidgets([...DEFAULT_STATS_WIDGETS]) + showWidgetPicker() + }, + }, + ]} + /> + )) + } + const visibleInsights = createMemo(() => cachedInsights() ?? insights()) + const visibleWidgetSet = createMemo>(() => new Set(widgets())) createEffect(() => { const data = visibleInsights() if (!data || data.days.length === 0) return @@ -1384,6 +1607,7 @@ export function Stats() { days: data.days, selectedDay: selectedDayKey(), selectedIndex: selectedDayIndex(), + followLatest: !selectedDayPinned(), }) if (nextSelectedDayIndex === undefined) return const nextSelectedDayKey = data.days[nextSelectedDayIndex]?.day @@ -1399,31 +1623,17 @@ export function Stats() { if (!data || data.days.length === 0) return const next = Math.max(0, Math.min(data.days.length - 1, index)) const key = data.days[next]?.day - const current = pendingSelectedDay?.index ?? selectedDayIndex() - if (current === next && pendingSelectedDay === undefined) return - pendingSelectedDay = { index: next, key } - setSelectedDayLoading(true) - if (selectedDayTimer) return - selectedDayTimer = setTimeout(() => { - const pending = pendingSelectedDay - pendingSelectedDay = undefined - selectedDayTimer = undefined - if (!pending) { - setSelectedDayLoading(false) - return - } - batch(() => { - setSelectedDayIndex(pending.index) - setSelectedDayKey(pending.key) - setSelectedDayLoading(false) - }) - }, 0) + batch(() => { + setSelectedDayPinned(true) + setSelectedDayIndex(next) + setSelectedDayKey(key) + }) } function moveSelectedDay(delta: number) { const data = visibleInsights() if (!data || data.days.length === 0) return - selectDay((pendingSelectedDay?.index ?? selectedDayIndex() ?? data.days.length - 1) + delta) + selectDay((selectedDayIndex() ?? data.days.length - 1) + delta) } function moveSelectedColumn(delta: number) { @@ -1466,6 +1676,13 @@ export function Stats() { evt.preventDefault() evt.stopPropagation() if (!refreshingInsights()) setInsightRefreshRequest((value) => value + 1) + setSubscriptionRefreshTick((value) => value + 1) + return + } + if (evt.name === "c") { + evt.preventDefault() + evt.stopPropagation() + showWidgetPicker() return } if (evt.name === "w") { @@ -1602,9 +1819,19 @@ export function Stats() { return ( `${scope() === "global" ? "global" : scope() === "project" ? "project" : "directory"} · ${mode()} · ${advanced() ? ADVANCED_DAYS : DEFAULT_DAYS}d`} + subtitle={() => + `${scope() === "global" ? "global" : scope() === "project" ? "project" : "directory"} · ${mode()} · ${advanced() ? ADVANCED_DAYS : DEFAULT_DAYS}d` + } status={() => - insightError() ? (visibleInsights() ? "STALE" : "ERROR") : refreshingInsights() ? "SYNCING" : visibleInsights() ? "LIVE" : "LOADING" + insightError() + ? visibleInsights() + ? "STALE" + : "ERROR" + : refreshingInsights() + ? "SYNCING" + : visibleInsights() + ? "LIVE" + : "LOADING" } summary={() => { const current = totals() @@ -1612,7 +1839,7 @@ export function Stats() { ? `${formatInsightNumber(current.sessions)} sessions · ${formatInsightNumber(current.tokens)} tokens · ${formatInsightDuration(current.aiResponseMs)} AI` : "loading insights" }} - footer="↑↓ Day ←→ Week A Details M/Tab Mode 1/2/3 Views R Refresh W Weather Esc/Q Back" + footer="↑↓ Day ←→ Week A Details M/Tab Mode C Widgets R Refresh W Weather Esc/Q Back" > @@ -1644,10 +1871,13 @@ export function Stats() { contentWidth={deck().contentWidth} selectedDay={selectedDay()} selectedDayIndex={selectedDayIndex()} - activityLoading={selectedDayLoading()} tokenRows={tokenRows()} responseRows={responseRows()} statusRows={statusRows()} + widgets={visibleWidgetSet()} + subscriptionUsage={subscriptionUsage()} + subscriptionLoading={subscriptionUsage.loading} + subscriptionError={subscriptionError()} tall={true} /> @@ -1663,7 +1893,6 @@ export function Stats() { heatCellWidth={heatCellWidth()} selectedDay={selectedDay()} selectedDayIndex={selectedDayIndex()} - activityLoading={selectedDayLoading()} kpis={kpis()} tokenRows={tokenRows()} responseRows={responseRows()} @@ -1675,6 +1904,10 @@ export function Stats() { weatherLoading={weather.loading || (weatherConfig().enabled && !weatherReady())} weatherError={weatherError()} onConfigureWeather={configureWeather} + widgets={visibleWidgetSet()} + subscriptionUsage={subscriptionUsage()} + subscriptionLoading={subscriptionUsage.loading} + subscriptionError={subscriptionError()} /> diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/stats/subscription-usage.ts b/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/stats/subscription-usage.ts new file mode 100644 index 00000000..4913f63b --- /dev/null +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/routes/stats/subscription-usage.ts @@ -0,0 +1,257 @@ +import { open, readdir } from "node:fs/promises" +import path from "node:path" +import { Global } from "@mendcode/core/global" + +export type SubscriptionUsageWindow = { + label: string + usedPercent: number + windowMinutes: number + resetsAt?: number +} + +export type SubscriptionUsageSnapshot = { + provider: "codex" + plan?: string + updatedAt?: number + windows: SubscriptionUsageWindow[] +} + +type CodexRateWindow = { + used_percent?: unknown + window_minutes?: unknown + resets_at?: unknown +} + +type CodexAppRateWindow = { + usedPercent?: unknown + windowDurationMins?: unknown + resetsAt?: unknown +} + +type CodexAppRateLimits = { + planType?: unknown + primary?: unknown + secondary?: unknown +} + +function finiteNumber(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +export function subscriptionWindowLabel(minutes: number) { + if (minutes >= 24 * 60 && minutes % (24 * 60) === 0) return `${minutes / (24 * 60)}d` + if (minutes >= 60 && minutes % 60 === 0) return `${minutes / 60}h` + return `${minutes}m` +} + +function parseWindow(value: unknown): SubscriptionUsageWindow | undefined { + if (!value || typeof value !== "object") return undefined + const input = value as CodexRateWindow + const usedPercent = finiteNumber(input.used_percent) + const windowMinutes = finiteNumber(input.window_minutes) + if (usedPercent === undefined || windowMinutes === undefined || windowMinutes <= 0) return undefined + const resetsAt = finiteNumber(input.resets_at) + return { + label: subscriptionWindowLabel(windowMinutes), + usedPercent: Math.max(0, Math.min(100, usedPercent)), + windowMinutes, + ...(resetsAt === undefined ? {} : { resetsAt: resetsAt * 1000 }), + } +} + +function parseAppWindow(value: unknown): SubscriptionUsageWindow | undefined { + if (!value || typeof value !== "object") return undefined + const input = value as CodexAppRateWindow + const usedPercent = finiteNumber(input.usedPercent) + const windowMinutes = finiteNumber(input.windowDurationMins) + if (usedPercent === undefined || windowMinutes === undefined || windowMinutes <= 0) return undefined + const resetsAt = finiteNumber(input.resetsAt) + return { + label: subscriptionWindowLabel(windowMinutes), + usedPercent: Math.max(0, Math.min(100, usedPercent)), + windowMinutes, + ...(resetsAt === undefined ? {} : { resetsAt: resetsAt * 1000 }), + } +} + +export function parseCodexAppServerUsage(input: unknown): SubscriptionUsageSnapshot | undefined { + if (!input || typeof input !== "object") return undefined + const result = input as { + rateLimits?: CodexAppRateLimits + rateLimitsByLimitId?: Record | null + } + const limits = result.rateLimitsByLimitId?.codex ?? result.rateLimits + if (!limits) return undefined + const windows = [parseAppWindow(limits.primary), parseAppWindow(limits.secondary)].filter( + (item): item is SubscriptionUsageWindow => Boolean(item), + ) + if (windows.length === 0) return undefined + return { + provider: "codex", + plan: typeof limits.planType === "string" ? limits.planType : undefined, + updatedAt: Date.now(), + windows, + } +} + +export function parseCodexSubscriptionUsage(input: string): SubscriptionUsageSnapshot | undefined { + const lines = input.split("\n") + for (let index = lines.length - 1; index >= 0; index--) { + const line = lines[index]?.trim() + if (!line) continue + try { + const event = JSON.parse(line) as { + timestamp?: unknown + payload?: { + type?: unknown + info?: { + rate_limits?: { + plan_type?: unknown + primary?: unknown + secondary?: unknown + } + } + } + } + if (event.payload?.type !== "token_count") continue + const limits = event.payload.info?.rate_limits + if (!limits) continue + const windows = [parseWindow(limits.primary), parseWindow(limits.secondary)].filter( + (item): item is SubscriptionUsageWindow => Boolean(item), + ) + if (windows.length === 0) continue + const timestamp = typeof event.timestamp === "string" ? Date.parse(event.timestamp) : Number.NaN + return { + provider: "codex", + plan: typeof limits.plan_type === "string" ? limits.plan_type : undefined, + updatedAt: Number.isFinite(timestamp) ? timestamp : undefined, + windows, + } + } catch { + continue + } + } + return undefined +} + +async function latestCodexSessionFile(root: string) { + let directories = [root] + for (let depth = 0; depth < 3; depth++) { + const next: string[] = [] + for (const directory of directories) { + const entries = await readdir(directory, { withFileTypes: true }).catch(() => []) + next.push( + ...entries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(directory, entry.name)) + .sort((left, right) => right.localeCompare(left)), + ) + } + if (next.length === 0) return undefined + directories = next.slice(0, 4) + } + + const files = ( + await Promise.all( + directories.map(async (directory) => { + const entries = await readdir(directory, { withFileTypes: true }).catch(() => []) + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) + .map((entry) => path.join(directory, entry.name)) + }), + ) + ).flat() + return files.sort((left, right) => right.localeCompare(left))[0] +} + +async function readFileTail(file: string, bytes = 512 * 1024) { + const handle = await open(file, "r") + try { + const size = (await handle.stat()).size + const length = Math.min(size, bytes) + const buffer = Buffer.alloc(length) + await handle.read(buffer, 0, length, Math.max(0, size - length)) + return buffer.toString("utf8") + } finally { + await handle.close() + } +} + +async function readCodexAppServerUsage() { + const process = Bun.spawn(["codex", "app-server", "--stdio"], { + stdin: "pipe", + stdout: "pipe", + stderr: "ignore", + }) + const reader = process.stdout.getReader() + const decoder = new TextDecoder() + let pending = "" + + async function response(id: number, timeoutMs: number) { + const read = async () => { + while (true) { + const chunk = await reader.read() + if (chunk.done) return undefined + pending += decoder.decode(chunk.value, { stream: true }) + let newline = pending.indexOf("\n") + while (newline >= 0) { + const line = pending.slice(0, newline) + pending = pending.slice(newline + 1) + newline = pending.indexOf("\n") + try { + const value = JSON.parse(line) as { id?: unknown; result?: unknown } + if (value.id === id) return value.result + } catch { + continue + } + } + } + } + let timer: ReturnType | undefined + try { + return await Promise.race([ + read(), + new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), timeoutMs) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } + } + + try { + process.stdin.write( + `${JSON.stringify({ + method: "initialize", + id: 1, + params: { + clientInfo: { name: "mendcode-stats", title: "MendCode Stats", version: "1" }, + capabilities: { experimentalApi: true, requestAttestation: false }, + }, + })}\n`, + ) + process.stdin.flush() + if (!(await response(1, 1_500))) return undefined + process.stdin.write( + `${JSON.stringify({ method: "initialized" })}\n${JSON.stringify({ + method: "account/rateLimits/read", + id: 2, + })}\n`, + ) + process.stdin.flush() + return parseCodexAppServerUsage(await response(2, 3_000)) + } finally { + process.kill() + await reader.cancel().catch(() => undefined) + } +} + +export async function loadSubscriptionUsage() { + const live = await readCodexAppServerUsage().catch(() => undefined) + if (live) return live + const root = path.join(Global.Path.home, ".codex", "sessions") + const file = await latestCodexSessionFile(root) + if (!file) return undefined + return parseCodexSubscriptionUsage(await readFileTail(file)) +} diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/util/agent-view.ts b/src/mendcode/packages/opencode/src/cli/cmd/tui/util/agent-view.ts index f8267d00..a89467f6 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/util/agent-view.ts +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/util/agent-view.ts @@ -139,13 +139,14 @@ export type AgentViewCommand = { id: string sourceSessionID: string targetSessionID: string - type: "request_summary" | "rename" | "tag" | "pause_after_turn" | "stop" | "send_message" + type: "request_summary" | "rename" | "tag" | "pause_after_turn" | "stop" | "send_message" | "peer_message" payload?: { instructions?: string | null title?: string | null tags?: readonly string[] | null reason?: string | null text?: string | null + sourceTitle?: string | null } | null permissions?: readonly string[] | null state: "pending" | "accepted" | "running" | "completed" | "rejected" | "failed" | "expired" @@ -389,7 +390,8 @@ export function summarizeAgentViewOrchestration(input: { } export function formatAgentViewOrchestrationSummary(input: AgentViewOrchestrationSummary) { - const pendingLabel = input.pendingCapacity > 0 ? `${input.pending}/${input.pendingCapacity} queued` : `${input.pending} queued` + const pendingLabel = + input.pendingCapacity > 0 ? `${input.pending}/${input.pendingCapacity} queued` : `${input.pending} queued` const limitLabel = input.overLimitTargets > 0 ? ` · ${input.overLimitTargets} over limit` : "" return `Coordinator commands · ${pendingLabel}${limitLabel} · ${input.active} running · ${input.completed} done · ${input.blocked} blocked` } @@ -400,6 +402,7 @@ export function formatAgentViewCommandType(command: Pick `#${tag}`).join(" ") - : command.type === "send_message" + : command.type === "send_message" || command.type === "peer_message" ? command.payload?.text - : command.payload?.instructions ?? command.payload?.reason + : (command.payload?.instructions ?? command.payload?.reason) const state = command.state === "pending" ? "pending" : command.state - return Locale.truncate( - [state, action, detail] - .filter(Boolean) - .join(" · ") - .replace(/\s+/g, " ") - .trim(), - 120, - ) + return Locale.truncate([state, action, detail].filter(Boolean).join(" · ").replace(/\s+/g, " ").trim(), 120) } diff --git a/src/mendcode/packages/opencode/src/mend/cli/background-daemon.ts b/src/mendcode/packages/opencode/src/mend/cli/background-daemon.ts index 0481a1b4..d5f1debf 100644 --- a/src/mendcode/packages/opencode/src/mend/cli/background-daemon.ts +++ b/src/mendcode/packages/opencode/src/mend/cli/background-daemon.ts @@ -5,6 +5,7 @@ import { Database as BunDatabase } from "bun:sqlite" import { Global } from "@mendcode/core/global" import { resolveDualReadDbPathFromLayout } from "@/storage/resolve-default-sqlite-path" import { resolveMendProjectRoot } from "../config/paths" +import { loopProjectScopeSql } from "../runtime/loop-project-scope" const scheduledLoopModes = new Set(["interval", "daily", "adaptive", "external-signal", "self-paced"]) @@ -21,19 +22,20 @@ type DreamWindow = { } function objectValue(value: unknown) { - return typeof value === "object" && value !== null ? value as Record : undefined + return typeof value === "object" && value !== null ? (value as Record) : undefined } function loopMode(row: LoopRow) { - const data = typeof row.data === "string" - ? (() => { - try { - return JSON.parse(row.data) as unknown - } catch { - return undefined - } - })() - : row.data + const data = + typeof row.data === "string" + ? (() => { + try { + return JSON.parse(row.data) as unknown + } catch { + return undefined + } + })() + : row.data return objectValue(objectValue(data)?.spec)?.trigger } @@ -51,9 +53,12 @@ function envOpen(openKey: string) { function backgroundDatabasePath() { const override = envOpen("OPENCODE_DB") - if (override) return override === ":memory:" || path.isAbsolute(override) ? override : path.join(Global.Path.data, override) + if (override) + return override === ":memory:" || path.isAbsolute(override) ? override : path.join(Global.Path.data, override) const channel = envOpen("OPENCODE_CHANNEL") || "local" - const disabled = [envOpen("OPENCODE_DISABLE_CHANNEL_DB")].some((value) => value === "1" || value?.toLowerCase() === "true") + const disabled = [envOpen("OPENCODE_DISABLE_CHANNEL_DB")].some( + (value) => value === "1" || value?.toLowerCase() === "true", + ) return resolveDualReadDbPathFromLayout(Global.Path.data, channel, disabled) } @@ -72,17 +77,22 @@ function readLoopRows(root: string, now: number, dueOnly: boolean) { )` : "AND (w.state = 'working' OR w.state IN ('active', 'sleeping'))" const limit = dueOnly ? "LIMIT 20" : "" - const rows = sqlite.query(` + const rows = sqlite + .query( + ` SELECT w.state, w.data FROM loop_workflow AS w JOIN project AS p ON p.id = w.project_id - WHERE (p.worktree = ? OR EXISTS ( - SELECT 1 FROM workspace AS ws - WHERE ws.project_id = w.project_id AND ws.directory = ? - )) + WHERE ${loopProjectScopeSql("?")} ${duePredicate} ${limit} - `).all(...(dueOnly ? [path.resolve(root), path.resolve(root), now] : [path.resolve(root), path.resolve(root)])) as LoopRow[] + `, + ) + .all( + ...(dueOnly + ? [path.resolve(root), path.resolve(root), path.resolve(root), now] + : [path.resolve(root), path.resolve(root), path.resolve(root)]), + ) as LoopRow[] return rows } catch { // A missing/old database must fall back to the full path so migrations and recovery still run. @@ -148,13 +158,15 @@ function localMinutes(now: Date, timezone?: string) { export function dreamBackgroundHasWork(now = new Date()) { const globalDir = process.env.MENDCODE_MEMORY_DIR || path.join(Global.Path.data, "memory") const files = [path.join(globalDir, "config.json"), path.join(globalDir, "dream", "schedule.json")] - return Promise.all(files.map(async (file) => { - try { - return JSON.parse(await readFile(file, "utf8")) as unknown - } catch { - return undefined - } - })).then(([config, schedule]) => { + return Promise.all( + files.map(async (file) => { + try { + return JSON.parse(await readFile(file, "utf8")) as unknown + } catch { + return undefined + } + }), + ).then(([config, schedule]) => { const window = dreamWindow(objectValue(config)?.dreamWindow) ?? dreamWindow(objectValue(schedule)?.window) if (!window) return false const start = minutes(window.start)! @@ -165,7 +177,10 @@ export function dreamBackgroundHasWork(now = new Date()) { } function managedLoopService() { - return process.env.MENDCODE_LOOP_SERVICE === "1" || process.env.XPC_SERVICE_NAME?.startsWith("com.mendcode.loops.") === true + return ( + process.env.MENDCODE_LOOP_SERVICE === "1" || + process.env.XPC_SERVICE_NAME?.startsWith("com.mendcode.loops.") === true + ) } async function uninstallManagedLoopService(root: string) { @@ -184,7 +199,8 @@ export async function main(argv = process.argv.slice(2)) { } if (!loopBackgroundHasWork(root)) return } - if (once && argv[0] === "memory" && argv[1] === "dream" && argv[2] === "daemon" && !(await dreamBackgroundHasWork())) return + if (once && argv[0] === "memory" && argv[1] === "dream" && argv[2] === "daemon" && !(await dreamBackgroundHasWork())) + return await import("./control-plane") } diff --git a/src/mendcode/packages/opencode/src/mend/cli/control-plane.ts b/src/mendcode/packages/opencode/src/mend/cli/control-plane.ts index ea50592b..61ff197a 100644 --- a/src/mendcode/packages/opencode/src/mend/cli/control-plane.ts +++ b/src/mendcode/packages/opencode/src/mend/cli/control-plane.ts @@ -111,6 +111,7 @@ import { providerLogin } from "../runtime/auth" import { exportPlan } from "../runtime/export" import { DEFAULT_LOOP_SERVICE_LIMIT, + loopDaemonCanDispatch, loopServiceArgsFromConfig, loopServiceInstall, loopServicePlan, @@ -539,7 +540,10 @@ async function withWorkflowRuntime( } function workflowRunID(value?: string) { - if (!value) throw new Error("Usage: mend workflows ") + if (!value) + throw new Error( + "Usage: mend workflows ", + ) return Workflow.WorkflowRunID.make(value) } @@ -569,12 +573,19 @@ function formatWorkflowSnapshot(snapshot: WorkflowService.WorkflowSnapshot) { "", "Phases", ...(snapshot.phases.length - ? snapshot.phases.map((phase) => ` ${phase.ordinal}. ${phase.name.padEnd(18)} ${phase.state} ${phase.counts.completed}/${phase.counts.total}`) + ? snapshot.phases.map( + (phase) => + ` ${phase.ordinal}. ${phase.name.padEnd(18)} ${phase.state} ${phase.counts.completed}/${phase.counts.total}`, + ) : [" none"]), "", "Tasks", ...(snapshot.tasks.length - ? snapshot.tasks.slice(0, 40).map((task) => ` ${task.state.padEnd(10)} ${task.id} ${task.name}${task.blocker ? ` — ${task.blocker}` : ""}`) + ? snapshot.tasks + .slice(0, 40) + .map( + (task) => ` ${task.state.padEnd(10)} ${task.id} ${task.name}${task.blocker ? ` — ${task.blocker}` : ""}`, + ) : [" none"]), ].join("\n") } @@ -586,10 +597,14 @@ async function workflows(args: string[]) { Effect.gen(function* () { const initial = yield* workflow.list() const stranded = initial.filter((snapshot) => snapshot.run.state === "needs_input") - yield* Effect.forEach(stranded, (snapshot) => runner.run(snapshot.run.id).pipe(Effect.catchCause(() => Effect.void)), { - concurrency: 8, - discard: true, - }) + yield* Effect.forEach( + stranded, + (snapshot) => runner.run(snapshot.run.id).pipe(Effect.catchCause(() => Effect.void)), + { + concurrency: 8, + discard: true, + }, + ) const snapshots = stranded.length ? yield* workflow.list() : initial yield* Effect.forEach( snapshots.filter((snapshot) => snapshot.run.state === "queued" || snapshot.run.state === "working"), @@ -624,15 +639,20 @@ async function workflows(args: string[]) { const receipt = await withWorkflowService((workflow) => workflow.save({ plan, - definitionID: optionValue(args, "--definition-id") ? Workflow.WorkflowDefinitionID.make(optionValue(args, "--definition-id")!) : undefined, + definitionID: optionValue(args, "--definition-id") + ? Workflow.WorkflowDefinitionID.make(optionValue(args, "--definition-id")!) + : undefined, name: optionValue(args, "--name") ?? undefined, description: optionValue(args, "--description") ?? undefined, source: (optionValue(args, "--source") as Workflow.WorkflowDefinition["source"] | null) ?? undefined, saved: true, }), ) - printResult(args, receipt, (value: WorkflowService.WorkflowRevisionReceipt) => - `Saved workflow ${value.definitionID} revision ${value.revisionID} (#${value.revision})`, + printResult( + args, + receipt, + (value: WorkflowService.WorkflowRevisionReceipt) => + `Saved workflow ${value.definitionID} revision ${value.revisionID} (#${value.revision})`, ) return } @@ -642,12 +662,18 @@ async function workflows(args: string[]) { Effect.gen(function* () { const snapshot = yield* workflow.start({ plan: planFile ? ((yield* Effect.promise(() => workflowPlan(args))) as WorkflowPlan) : undefined, - revisionID: optionValue(args, "--revision-id") ? Workflow.WorkflowRevisionID.make(optionValue(args, "--revision-id")!) : undefined, - definitionID: optionValue(args, "--definition-id") ? Workflow.WorkflowDefinitionID.make(optionValue(args, "--definition-id")!) : undefined, + revisionID: optionValue(args, "--revision-id") + ? Workflow.WorkflowRevisionID.make(optionValue(args, "--revision-id")!) + : undefined, + definitionID: optionValue(args, "--definition-id") + ? Workflow.WorkflowDefinitionID.make(optionValue(args, "--definition-id")!) + : undefined, name: optionValue(args, "--name") ?? undefined, description: optionValue(args, "--description") ?? undefined, source: (optionValue(args, "--source") as Workflow.WorkflowDefinition["source"] | null) ?? undefined, - originSessionID: optionValue(args, "--origin-session") ? (SessionID.make(optionValue(args, "--origin-session")!) as SessionID) : undefined, + originSessionID: optionValue(args, "--origin-session") + ? (SessionID.make(optionValue(args, "--origin-session")!) as SessionID) + : undefined, }) yield* runner.start(snapshot.run.id) return snapshot @@ -660,7 +686,8 @@ async function workflows(args: string[]) { const snapshot = await withWorkflowRuntime((workflow, runner) => Effect.gen(function* () { const initial = yield* workflow.show(workflowRunID(args[1])) - if (initial.run.state === "needs_input") yield* runner.run(initial.run.id).pipe(Effect.catchCause(() => Effect.void)) + if (initial.run.state === "needs_input") + yield* runner.run(initial.run.id).pipe(Effect.catchCause(() => Effect.void)) const current = initial.run.state === "needs_input" ? yield* workflow.show(initial.run.id) : initial if (current.run.state === "queued" || current.run.state === "working") yield* runner.start(current.run.id) return current @@ -678,12 +705,20 @@ async function workflows(args: string[]) { if (sub === "events" || sub === "artifacts") { const runID = workflowRunID(args[1]) if (sub === "events") { - const items = await withWorkflowService((workflow) => workflow.events(runID, Number(optionValue(args, "--limit") ?? 50))) - printResult(args, items, (value: readonly unknown[]) => value.length ? value.map((item) => JSON.stringify(item)).join("\n") : "No workflow events.") + const items = await withWorkflowService((workflow) => + workflow.events(runID, Number(optionValue(args, "--limit") ?? 50)), + ) + printResult(args, items, (value: readonly unknown[]) => + value.length ? value.map((item) => JSON.stringify(item)).join("\n") : "No workflow events.", + ) return } - const items = await withWorkflowService((workflow) => workflow.artifacts(runID, Number(optionValue(args, "--limit") ?? 50))) - printResult(args, items, (value: readonly unknown[]) => value.length ? value.map((item) => JSON.stringify(item)).join("\n") : "No workflow artifacts.") + const items = await withWorkflowService((workflow) => + workflow.artifacts(runID, Number(optionValue(args, "--limit") ?? 50)), + ) + printResult(args, items, (value: readonly unknown[]) => + value.length ? value.map((item) => JSON.stringify(item)).join("\n") : "No workflow artifacts.", + ) return } if (["pause", "resume", "stop", "retry-task", "retry-phase"].includes(sub)) { @@ -703,12 +738,22 @@ async function workflows(args: string[]) { } else if (sub === "retry-task") { const taskID = optionValue(args, "--task-id") if (!taskID) throw new Error("Usage: mend workflows retry-task --task-id ") - snapshot = yield* workflow.retryTask({ runID, taskID: Workflow.WorkflowTaskID.make(taskID), reason: optionValue(args, "--reason") ?? undefined, actor: "cli" }) + snapshot = yield* workflow.retryTask({ + runID, + taskID: Workflow.WorkflowTaskID.make(taskID), + reason: optionValue(args, "--reason") ?? undefined, + actor: "cli", + }) yield* runner.start(snapshot.run.id) } else { const phaseID = optionValue(args, "--phase-id") if (!phaseID) throw new Error("Usage: mend workflows retry-phase --phase-id ") - snapshot = yield* workflow.retryPhase({ runID, phaseID: Workflow.WorkflowPhaseID.make(phaseID), reason: optionValue(args, "--reason") ?? undefined, actor: "cli" }) + snapshot = yield* workflow.retryPhase({ + runID, + phaseID: Workflow.WorkflowPhaseID.make(phaseID), + reason: optionValue(args, "--reason") ?? undefined, + actor: "cli", + }) yield* runner.start(snapshot.run.id) } return snapshot @@ -717,7 +762,9 @@ async function workflows(args: string[]) { printResult(args, result, formatWorkflowSnapshot) return } - throw new Error("Usage: mend workflows ") + throw new Error( + "Usage: mend workflows ", + ) } async function loops(args: string[]) { @@ -760,10 +807,10 @@ async function loops(args: string[]) { templateID: template?.id, trigger: dailyAt || timezone - ? { mode: "daily", dailyAt: dailyAt ?? undefined, timezone: timezone ?? undefined } - : interval - ? { mode: "interval", intervalMs: Number(interval) } - : template?.trigger, + ? { mode: "daily", dailyAt: dailyAt ?? undefined, timezone: timezone ?? undefined } + : interval + ? { mode: "interval", intervalMs: Number(interval) } + : template?.trigger, gates: template?.gates, stopWhen: template?.stopWhen, policy: template?.policy, @@ -847,11 +894,17 @@ async function loops(args: string[]) { let latestHealthWakeAttempt = 0 let healthWrite = Promise.resolve() const recordHealth = (input: { lastWakeAttempt?: number; lastError?: string; degraded: boolean }) => { - if (input.lastWakeAttempt !== undefined && input.lastWakeAttempt < latestHealthWakeAttempt) return Promise.resolve() + if (input.lastWakeAttempt !== undefined && input.lastWakeAttempt < latestHealthWakeAttempt) + return Promise.resolve() latestHealthWakeAttempt = Math.max(latestHealthWakeAttempt, input.lastWakeAttempt ?? 0) - healthWrite = healthWrite.then(() => writeLoopServiceHealth(servicePlan, input)).catch((error) => { - if (!quiet) console.error(`WARN: loop service health write failed: ${error instanceof Error ? error.message : String(error)}`) - }) + healthWrite = healthWrite + .then(() => writeLoopServiceHealth(servicePlan, input)) + .catch((error) => { + if (!quiet) + console.error( + `WARN: loop service health write failed: ${error instanceof Error ? error.message : String(error)}`, + ) + }) return healthWrite } const runTick = async (disposeRuntime: boolean) => { @@ -870,7 +923,11 @@ async function loops(args: string[]) { return } const schedulerFailure = results.find((item) => item.summary.startsWith("Loop scheduler failed")) - await recordHealth({ lastWakeAttempt: startedAt, lastError: schedulerFailure?.summary, degraded: Boolean(schedulerFailure) }) + await recordHealth({ + lastWakeAttempt: startedAt, + lastError: schedulerFailure?.summary, + degraded: Boolean(schedulerFailure), + }) if (results.length) { for (const item of results) { console.log( @@ -891,7 +948,7 @@ async function loops(args: string[]) { } const activeTicks = new Set>() const dispatchTick = () => { - if (activeTicks.size >= Math.max(1, limit)) return + if (!loopDaemonCanDispatch(activeTicks.size)) return const tick = runTick(false).finally(() => activeTicks.delete(tick)) activeTicks.add(tick) } @@ -1336,37 +1393,37 @@ async function prompt(args: string[]) { console.log( JSON.stringify( { - mode: policy.mode, - focusID: policy.focusID, + mode: policy.mode, + focusID: policy.focusID, promptOrigin: policy.promptOrigin, - modelID, - role, - workflow, - basePromptSource: policy.basePromptSource, - usesMendCodeHarnessPrompt: policy.usesMendCodeHarnessPrompt, - usesOpenCodeGenericProviderPrompt: policy.usesOpenCodeGenericProviderPrompt, - fallbackReason: policy.fallbackReason, + modelID, + role, + workflow, + basePromptSource: policy.basePromptSource, + usesMendCodeHarnessPrompt: policy.usesMendCodeHarnessPrompt, + usesOpenCodeGenericProviderPrompt: policy.usesOpenCodeGenericProviderPrompt, + fallbackReason: policy.fallbackReason, customPrompt: policy.customPrompt, - includeProjectInstructions: policy.includeProjectInstructions, - includeSkillsByDefault: policy.includeSkillsByDefault, - includeCustomInstructions: policy.includeCustomInstructions, - includeMcpContext: policy.includeMcpContext, - source: policy.source, - sections: policy.sections.map((section) => ({ - id: section.id, - label: section.label, - source: section.source, - bytes: section.bytes, - preview: section.preview, - })), - basePromptBytes: policy.basePromptBytes, - instructionsBytes: policy.instructionsBytes, - instructionsPreview: policy.instructionsPreview, - policyInstructionsBytes: policy.policyInstructionsBytes, - policyInstructionsPreview: policy.policyInstructionsPreview, - ...(showFull ? { instructions: policy.instructions } : {}), - printsFullPrompt: showFull, - note: "Use run/chat --prompt-mode to execute with this policy. This command does not call providers.", + includeProjectInstructions: policy.includeProjectInstructions, + includeSkillsByDefault: policy.includeSkillsByDefault, + includeCustomInstructions: policy.includeCustomInstructions, + includeMcpContext: policy.includeMcpContext, + source: policy.source, + sections: policy.sections.map((section) => ({ + id: section.id, + label: section.label, + source: section.source, + bytes: section.bytes, + preview: section.preview, + })), + basePromptBytes: policy.basePromptBytes, + instructionsBytes: policy.instructionsBytes, + instructionsPreview: policy.instructionsPreview, + policyInstructionsBytes: policy.policyInstructionsBytes, + policyInstructionsPreview: policy.policyInstructionsPreview, + ...(showFull ? { instructions: policy.instructions } : {}), + printsFullPrompt: showFull, + note: "Use run/chat --prompt-mode to execute with this policy. This command does not call providers.", }, null, 2, @@ -1636,14 +1693,14 @@ async function mflow(args: string[]) { const hookPriority = hookPriorityRaw === null ? undefined : Number(hookPriorityRaw) const result = await activateMflow( { - relayMode: relay, - signaling, - room, - secret, - generateSecret: !secret, - storeSecret: args.includes("--store-secret"), - hookPriority, - publicRelayNoticeAccepted: args.includes("--accept-public-relay-limits") || relay !== "legacy-public", + relayMode: relay, + signaling, + room, + secret, + generateSecret: !secret, + storeSecret: args.includes("--store-secret"), + hookPriority, + publicRelayNoticeAccepted: args.includes("--accept-public-relay-limits") || relay !== "legacy-public", }, root, ) @@ -1705,13 +1762,13 @@ async function mflow(args: string[]) { (await rl.question("Store secret in .mflow/config.toml? [y/N]: ")).trim().toLowerCase() === "y" const result = await activateMflow( { - relayMode, - signaling, - room, - secret, - generateSecret: generate, - storeSecret, - publicRelayNoticeAccepted, + relayMode, + signaling, + room, + secret, + generateSecret: generate, + storeSecret, + publicRelayNoticeAccepted, }, root, ) @@ -1747,20 +1804,20 @@ async function tsm(args: string[]) { const muxBackend = optionValue(args, "--mux") as TsmState["defaultMuxBackend"] | null const result = sub === "status" - ? await tsmStatus(root) - : sub === "plan" || sub === "install" - ? await tsmPlan(root) - : sub === "setup" - ? await setupTsm(root) - : sub === "activate" - ? await activateTsm(root, { muxBackend: muxBackend || undefined }) - : sub === "deactivate" - ? await deactivateTsm(root) - : sub === "remove" - ? await removeTsm(root) - : sub === "doctor" - ? await tsmDoctor(root) - : null + ? await tsmStatus(root) + : sub === "plan" || sub === "install" + ? await tsmPlan(root) + : sub === "setup" + ? await setupTsm(root) + : sub === "activate" + ? await activateTsm(root, { muxBackend: muxBackend || undefined }) + : sub === "deactivate" + ? await deactivateTsm(root) + : sub === "remove" + ? await removeTsm(root) + : sub === "doctor" + ? await tsmDoctor(root) + : null if (!result) throw new Error("Usage: mend-control-plane tsm ") printResult(args, result, formatTsmResult) @@ -1773,22 +1830,22 @@ async function worktree(args: string[]) { const rest = args.slice(1) const result = sub === "status" - ? await worktreeStatus(root) - : sub === "plan" - ? await worktreePlan(rest, root) - : sub === "create" - ? await worktreeCreate(rest, root) - : sub === "open" - ? await worktreeOpen(rest, root) - : sub === "adopt" - ? await worktreeAdopt(rest, root) - : sub === "remove" - ? await worktreeRemove(rest, root) - : sub === "reset" - ? await worktreeReset(rest, root) - : sub === "doctor" - ? await worktreeDoctor(root) - : null + ? await worktreeStatus(root) + : sub === "plan" + ? await worktreePlan(rest, root) + : sub === "create" + ? await worktreeCreate(rest, root) + : sub === "open" + ? await worktreeOpen(rest, root) + : sub === "adopt" + ? await worktreeAdopt(rest, root) + : sub === "remove" + ? await worktreeRemove(rest, root) + : sub === "reset" + ? await worktreeReset(rest, root) + : sub === "doctor" + ? await worktreeDoctor(root) + : null if (!result) throw new Error("Usage: mend-control-plane worktree ") printResult(args, result, formatWorktreeResult) if ("ok" in result && result.ok === false) process.exitCode = 1 @@ -2045,8 +2102,8 @@ async function memory(args: string[]) { const query = args .slice(1) .filter((arg, index, all) => { - const prev = all[index - 1] - return !arg.startsWith("--") && prev !== "--mode" + const prev = all[index - 1] + return !arg.startsWith("--") && prev !== "--mode" }) .join(" ") .trim() @@ -2054,14 +2111,14 @@ async function memory(args: string[]) { console.log( JSON.stringify( { - query, - mode, - enabled: result.enabled, - use: result.use, - callsProviders: false, - summaries: result.summaries, - entries: result.entries, - lines: result.lines, + query, + mode, + enabled: result.enabled, + use: result.use, + callsProviders: false, + summaries: result.summaries, + entries: result.entries, + lines: result.lines, }, null, 2, @@ -2074,8 +2131,8 @@ async function memory(args: string[]) { const query = args .slice(1) .filter((arg, index, all) => { - const prev = all[index - 1] - return !arg.startsWith("--") && prev !== "--provider" && prev !== "--model" && prev !== "--mode" + const prev = all[index - 1] + return !arg.startsWith("--") && prev !== "--provider" && prev !== "--model" && prev !== "--mode" }) .join(" ") .trim() @@ -2086,15 +2143,15 @@ async function memory(args: string[]) { console.log( JSON.stringify( { - query, - mode, - providerID, - modelID, - enabled: result.enabled, - use: result.use, - callsProviders: false, - entries: result.entries, - promptBlock: result.lines?.length ? formatMemoryBlock({ model, lines: result.lines }) : "", + query, + mode, + providerID, + modelID, + enabled: result.enabled, + use: result.use, + callsProviders: false, + entries: result.entries, + promptBlock: result.lines?.length ? formatMemoryBlock({ model, lines: result.lines }) : "", }, null, 2, @@ -2111,8 +2168,8 @@ async function memory(args: string[]) { const text = args .slice(1) .filter((arg, index, all) => { - const prev = all[index - 1] - return !arg.startsWith("--") && prev !== "--scope" && prev !== "--tags" + const prev = all[index - 1] + return !arg.startsWith("--") && prev !== "--scope" && prev !== "--tags" }) .join(" ") .trim() @@ -2127,8 +2184,8 @@ async function memory(args: string[]) { const text = args .slice(2) .filter((arg, index, all) => { - const prev = all[index - 1] - return !arg.startsWith("--") && prev !== "--scope" + const prev = all[index - 1] + return !arg.startsWith("--") && prev !== "--scope" }) .join(" ") .trim() @@ -2161,7 +2218,7 @@ async function memory(args: string[]) { const text = args .slice(1) .filter((arg, index, all) => { - const prev = all[index - 1] + const prev = all[index - 1] return ( !arg.startsWith("--") && prev !== "--scope" && @@ -2217,9 +2274,9 @@ async function memory(args: string[]) { if (sub === "import-codex") { const result = await importCodexMemories( { - codexMemoryDir: optionValue(args, "--from") || undefined, - apply: args.includes("--apply"), - maxProposals: optionValue(args, "--max-proposals") ? Number(optionValue(args, "--max-proposals")) : undefined, + codexMemoryDir: optionValue(args, "--from") || undefined, + apply: args.includes("--apply"), + maxProposals: optionValue(args, "--max-proposals") ? Number(optionValue(args, "--max-proposals")) : undefined, }, root, ) @@ -2675,16 +2732,16 @@ async function project(args: string[]) { const value = (name: string) => optionValue(args, name) const result = await packageMetadataSet( { - id: value("--id"), - title: value("--title"), - description: value("--description"), - version: value("--version"), - kind: value("--kind"), - channel: value("--channel"), - sourceType: value("--source-type"), - sourceURL: value("--source-url"), - compatMendcode: value("--compat-mendcode"), - compatRuntimePack: value("--compat-runtime-pack"), + id: value("--id"), + title: value("--title"), + description: value("--description"), + version: value("--version"), + kind: value("--kind"), + channel: value("--channel"), + sourceType: value("--source-type"), + sourceURL: value("--source-url"), + compatMendcode: value("--compat-mendcode"), + compatRuntimePack: value("--compat-runtime-pack"), }, root, ) diff --git a/src/mendcode/packages/opencode/src/mend/runtime/loop-project-scope.ts b/src/mendcode/packages/opencode/src/mend/runtime/loop-project-scope.ts new file mode 100644 index 00000000..7a4ce424 --- /dev/null +++ b/src/mendcode/packages/opencode/src/mend/runtime/loop-project-scope.ts @@ -0,0 +1,9 @@ +export function loopProjectScopeSql(project: string) { + return `(p.worktree = ${project} OR EXISTS ( + SELECT 1 FROM workspace AS ws + WHERE ws.project_id = w.project_id AND ws.directory = ${project} + ) OR EXISTS ( + SELECT 1 FROM session AS s + WHERE s.id = w.root_session_id AND s.project_id = w.project_id AND s.directory = ${project} + ))` +} diff --git a/src/mendcode/packages/opencode/src/mend/runtime/loop-service.ts b/src/mendcode/packages/opencode/src/mend/runtime/loop-service.ts index 4952e045..f5dca4b3 100644 --- a/src/mendcode/packages/opencode/src/mend/runtime/loop-service.ts +++ b/src/mendcode/packages/opencode/src/mend/runtime/loop-service.ts @@ -6,6 +6,7 @@ import path from "path" import { computeGlobalRoots, resolveActiveAppSegment } from "@mendcode/core/global-layout" import { resolveDualReadDbPathFromLayout } from "@/storage/resolve-default-sqlite-path" import { readMendConfig } from "../config/project" +import { loopProjectScopeSql } from "./loop-project-scope" export type LoopServiceMode = "dry-run" | "report-only" | "execute" export type LoopServicePlatform = "darwin" | "linux" | "win32" @@ -101,6 +102,10 @@ export const DEFAULT_LOOP_SERVICE_LIMIT = 10 const maxHealthErrorChars = 1_000 const maxHealthDriftItems = 12 +export function loopDaemonCanDispatch(activeTickCount: number) { + return activeTickCount === 0 +} + function stableProjectID(projectRoot: string) { return createHash("sha256").update(path.resolve(projectRoot)).digest("hex").slice(0, 12) } @@ -142,7 +147,8 @@ function defaultServiceDir(platformValue: LoopServicePlatform) { const configured = process.env.MENDCODE_LOOP_SERVICE_DIR if (configured) return path.resolve(configured) if (platformValue === "darwin") return path.join(os.homedir(), "Library", "LaunchAgents") - if (platformValue === "linux") return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "systemd", "user") + if (platformValue === "linux") + return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "systemd", "user") return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "MendCode", "Loops") } @@ -150,7 +156,8 @@ function defaultLogDir(platformValue: LoopServicePlatform) { const configured = process.env.MENDCODE_LOOP_LOG_DIR if (configured) return path.resolve(configured) if (platformValue === "darwin") return path.join(os.homedir(), "Library", "Logs", "MendCode") - if (platformValue === "linux") return path.join(process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state"), "mendcode", "logs") + if (platformValue === "linux") + return path.join(process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state"), "mendcode", "logs") return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "MendCode", "Logs") } @@ -164,7 +171,9 @@ function loopDatabasePath() { const override = envOpen("OPENCODE_DB") if (override) return override === ":memory:" || path.isAbsolute(override) ? override : path.join(dataDir, override) const channel = envOpen("OPENCODE_CHANNEL") || "local" - const disabled = [envOpen("OPENCODE_DISABLE_CHANNEL_DB")].some((value) => value === "1" || value?.toLowerCase() === "true") + const disabled = [envOpen("OPENCODE_DISABLE_CHANNEL_DB")].some( + (value) => value === "1" || value?.toLowerCase() === "true", + ) return resolveDualReadDbPathFromLayout(dataDir, channel, disabled) } @@ -186,7 +195,8 @@ function serviceIntervalSeconds(intervalMs: number) { } function loopDaemonArgs( - args: Required> & Pick, + args: Required> & + Pick, options: { once?: boolean } = {}, ) { const daemonArgs = ["loops", "daemon", "--interval-ms", String(args.intervalMs), "--limit", String(args.limit)] @@ -201,14 +211,11 @@ function sqlLiteral(value: string) { return `'${value.replaceAll("'", "''")}'` } -function loopServicePreflightCommand(input: { - daemonArguments: string[] - databasePath: string - projectRoot: string -}) { +function loopServicePreflightCommand(input: { daemonArguments: string[]; databasePath: string; projectRoot: string }) { const project = sqlLiteral(input.projectRoot) - const scope = `(p.worktree = ${project} OR EXISTS (SELECT 1 FROM workspace AS ws WHERE ws.project_id = w.project_id AND ws.directory = ${project}))` - const scheduledMode = "(json_valid(w.data) = 0 OR json_extract(w.data, '$.spec.trigger.mode') IS NULL OR json_extract(w.data, '$.spec.trigger.mode') IN ('interval', 'daily', 'adaptive', 'external-signal', 'self-paced'))" + const scope = loopProjectScopeSql(project) + const scheduledMode = + "(json_valid(w.data) = 0 OR json_extract(w.data, '$.spec.trigger.mode') IS NULL OR json_extract(w.data, '$.spec.trigger.mode') IN ('interval', 'daily', 'adaptive', 'external-signal', 'self-paced'))" const query = [ "WITH scheduled AS (", `SELECT w.state, w.next_wakeup FROM loop_workflow AS w JOIN project AS p ON p.id = w.project_id WHERE ${scope} AND w.state IN ('working', 'needs_input', 'active', 'sleeping') AND ${scheduledMode}`, @@ -230,13 +237,17 @@ function configuredMode(value: unknown): LoopServiceMode { return value === "dry-run" || value === "execute" || value === "report-only" ? value : "report-only" } -export function loopServiceArgsFromConfig(projectRoot: string, overrides: Partial = {}): LoopServiceArgs { +export function loopServiceArgsFromConfig( + projectRoot: string, + overrides: Partial = {}, +): LoopServiceArgs { const root = path.resolve(projectRoot) const cfg = readMendConfig(root) const loop = cfg.loop && typeof cfg.loop === "object" ? cfg.loop : {} const mode = configuredMode(loop.defaultServiceMode) const cleanOverrides = Object.fromEntries(Object.entries(overrides).filter(([, value]) => value !== undefined)) - const projectPath = (value: unknown) => typeof value === "string" && value.trim() ? path.resolve(root, value) : undefined + const projectPath = (value: unknown) => + typeof value === "string" && value.trim() ? path.resolve(root, value) : undefined return { execute: mode !== "dry-run", reportOnly: mode !== "execute", @@ -271,13 +282,10 @@ export function loopServicePlan(args: LoopServiceArgs): LoopServicePlan { ? path.join(serviceDir, `${label}.service`) : path.join(serviceDir, `${label}.cmd`) const databasePath = loopDatabasePath() - const serviceProgramArguments = platformValue === "darwin" - ? [ - "/bin/sh", - "-c", - loopServicePreflightCommand({ daemonArguments: programArguments, databasePath, projectRoot }), - ] - : programArguments + const serviceProgramArguments = + platformValue === "darwin" + ? ["/bin/sh", "-c", loopServicePreflightCommand({ daemonArguments: programArguments, databasePath, projectRoot })] + : programArguments const programLine = shellQuote(programArguments) return { label, @@ -344,19 +352,31 @@ function executableLocation(command: string) { const lookup = process.platform === "win32" ? "where.exe" : "which" const result = spawnSync(lookup, [command], { encoding: "utf8" }) if (result.status !== 0) return undefined - return result.stdout.split(/\r?\n/).map((line) => line.trim()).find(Boolean) + return result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) } async function executableIdentity(command: string) { const executablePath = executableLocation(command) - if (!executablePath) return { executablePath: undefined, executableVersion: undefined, executableFingerprint: undefined } + if (!executablePath) + return { executablePath: undefined, executableVersion: undefined, executableFingerprint: undefined } const [executableFingerprint, executableVersion] = await Promise.all([ - readFile(executablePath).then(fingerprint).catch(() => undefined), - Promise.resolve().then(() => { - const result = spawnSync(executablePath, ["--version"], { encoding: "utf8", timeout: 5_000 }) - const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim() - return output.split(/\r?\n/).map((line) => line.trim()).find(Boolean)?.slice(0, 200) - }).catch(() => undefined), + readFile(executablePath) + .then(fingerprint) + .catch(() => undefined), + Promise.resolve() + .then(() => { + const result = spawnSync(executablePath, ["--version"], { encoding: "utf8", timeout: 5_000 }) + const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim() + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) + ?.slice(0, 200) + }) + .catch(() => undefined), ]) return { executablePath, executableVersion, executableFingerprint } } @@ -374,7 +394,8 @@ function parseHealthRecord(value: unknown): LoopServiceHealthRecord | undefined typeof record.degraded !== "boolean" || typeof record.updatedAt !== "number" || !Number.isFinite(record.updatedAt) - ) return undefined + ) + return undefined return { schema: 1, projectRoot: record.projectRoot, @@ -385,7 +406,10 @@ function parseHealthRecord(value: unknown): LoopServiceHealthRecord | undefined executableVersion: typeof record.executableVersion === "string" ? record.executableVersion : undefined, executableFingerprint: typeof record.executableFingerprint === "string" ? record.executableFingerprint : undefined, definitionFingerprint: record.definitionFingerprint, - lastWakeAttempt: typeof record.lastWakeAttempt === "number" && Number.isFinite(record.lastWakeAttempt) ? record.lastWakeAttempt : undefined, + lastWakeAttempt: + typeof record.lastWakeAttempt === "number" && Number.isFinite(record.lastWakeAttempt) + ? record.lastWakeAttempt + : undefined, lastError: typeof record.lastError === "string" ? boundedHealthText(record.lastError) : undefined, degraded: record.degraded, updatedAt: record.updatedAt, @@ -424,10 +448,12 @@ export async function writeLoopServiceHealth(plan: LoopServicePlan, update: Loop } function shellQuote(args: string[]) { - return args.map((arg) => { - if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(arg)) return arg - return `'${arg.replaceAll("'", "'\\''")}'` - }).join(" ") + return args + .map((arg) => { + if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(arg)) return arg + return `'${arg.replaceAll("'", "'\\''")}'` + }) + .join(" ") } export function loopServicePlist(plan: LoopServicePlan) { @@ -519,7 +545,11 @@ function runServiceCommand(plan: LoopServicePlan, command: string[]) { } function serviceLoaded(plan: LoopServicePlan) { - if (platform() !== plan.platform) return { loaded: false, detail: `service is configured for ${plan.platform}, current platform is ${process.platform}` } + if (platform() !== plan.platform) + return { + loaded: false, + detail: `service is configured for ${plan.platform}, current platform is ${process.platform}`, + } const result = runServiceCommand(plan, plan.statusCommand) return { loaded: result.status === 0, @@ -535,7 +565,8 @@ function kickstartService(plan: LoopServicePlan) { export async function loopServiceInstall(args: LoopServiceArgs) { const plan = loopServicePlan(args) - if (platform() !== plan.platform) throw new Error(`Loop service install target is ${plan.platform}, current platform is ${process.platform}.`) + if (platform() !== plan.platform) + throw new Error(`Loop service install target is ${plan.platform}, current platform is ${process.platform}.`) await mkdir(path.dirname(plan.definitionPath), { recursive: true }) await mkdir(path.dirname(plan.stdoutPath), { recursive: true }) await writeFile(plan.definitionPath, loopServiceDefinition(plan)) @@ -549,7 +580,8 @@ export async function loopServiceInstall(args: LoopServiceArgs) { export async function loopServiceUninstall(args: LoopServiceArgs) { const plan = loopServicePlan(args) - if (platform() !== plan.platform) throw new Error(`Loop service uninstall target is ${plan.platform}, current platform is ${process.platform}.`) + if (platform() !== plan.platform) + throw new Error(`Loop service uninstall target is ${plan.platform}, current platform is ${process.platform}.`) if (serviceLoaded(plan).loaded) runServiceCommand(plan, plan.uninstallCommand) await rm(plan.definitionPath, { force: true }) if (plan.platform === "linux") runServiceCommand(plan, ["systemctl", "--user", "daemon-reload"]) @@ -558,7 +590,8 @@ export async function loopServiceUninstall(args: LoopServiceArgs) { export async function loopServiceStart(args: LoopServiceArgs) { const plan = loopServicePlan(args) - if (platform() !== plan.platform) throw new Error(`Loop service start target is ${plan.platform}, current platform is ${process.platform}.`) + if (platform() !== plan.platform) + throw new Error(`Loop service start target is ${plan.platform}, current platform is ${process.platform}.`) const previousDefinition = await readFile(plan.definitionPath, "utf8").catch(() => undefined) const existing = serviceLoaded(plan) const installed = await loopServiceInstall(args) @@ -582,9 +615,11 @@ export async function loopServiceStart(args: LoopServiceArgs) { export async function loopServiceStop(args: LoopServiceArgs) { const plan = loopServicePlan(args) - if (platform() !== plan.platform) throw new Error(`Loop service stop target is ${plan.platform}, current platform is ${process.platform}.`) + if (platform() !== plan.platform) + throw new Error(`Loop service stop target is ${plan.platform}, current platform is ${process.platform}.`) const result = runServiceCommand(plan, plan.stopCommand) - if (result.status !== 0 && serviceLoaded(plan).loaded) throw new Error(result.stderr || result.stdout || `${plan.backend} stop failed`) + if (result.status !== 0 && serviceLoaded(plan).loaded) + throw new Error(result.stderr || result.stdout || `${plan.backend} stop failed`) return plan } diff --git a/src/mendcode/packages/opencode/src/mend/tui/presentation.ts b/src/mendcode/packages/opencode/src/mend/tui/presentation.ts index 8dc952f2..cd92daf8 100644 --- a/src/mendcode/packages/opencode/src/mend/tui/presentation.ts +++ b/src/mendcode/packages/opencode/src/mend/tui/presentation.ts @@ -263,8 +263,10 @@ function asMascot(value: unknown, fallback: MendActivityMascotConfig): MendActiv const rawStates = isRecord(value.states) ? value.states : {} const states: MendActivityMascotConfig["states"] = { ...fallback.states } for (const [phase, text] of Object.entries(rawStates)) { - if (phase !== "idle" && phase !== "error" && !Object.prototype.hasOwnProperty.call(activityMessages, phase)) continue - if (typeof text === "string" && text.trim()) states[phase as keyof MendActivityMascotConfig["states"]] = text.trimEnd() + if (phase !== "idle" && phase !== "error" && !Object.prototype.hasOwnProperty.call(activityMessages, phase)) + continue + if (typeof text === "string" && text.trim()) + states[phase as keyof MendActivityMascotConfig["states"]] = text.trimEnd() } return { enabled: typeof value.enabled === "boolean" ? value.enabled : fallback.enabled, @@ -290,8 +292,12 @@ export function resolveTuiPresentation(input: unknown): MendPresentationConfig { renderer: asMessageRenderer(message.renderer ?? raw.messageRenderer, defaults.message.renderer), }, input: { - pasteSummary: typeof inputConfig.pasteSummary === "boolean" ? inputConfig.pasteSummary : defaults.input.pasteSummary, - pasteSummaryMinChars: Math.max(1, Number(inputConfig.pasteSummaryMinChars) || defaults.input.pasteSummaryMinChars), + pasteSummary: + typeof inputConfig.pasteSummary === "boolean" ? inputConfig.pasteSummary : defaults.input.pasteSummary, + pasteSummaryMinChars: Math.max( + 1, + Number(inputConfig.pasteSummaryMinChars) || defaults.input.pasteSummaryMinChars, + ), }, reasoning: { defaultVisibility: asReasoningVisibility(reasoning.defaultVisibility, defaults.reasoning.defaultVisibility), @@ -301,24 +307,34 @@ export function resolveTuiPresentation(input: unknown): MendPresentationConfig { placement: asPlacement(activity.placement, defaults.activity.placement), maxLines: Math.max(1, Math.min(4, Number(activity.maxLines) || defaults.activity.maxLines)), collapseOnComplete: - typeof activity.collapseOnComplete === "boolean" ? activity.collapseOnComplete : defaults.activity.collapseOnComplete, + typeof activity.collapseOnComplete === "boolean" + ? activity.collapseOnComplete + : defaults.activity.collapseOnComplete, showModel: typeof activity.showModel === "boolean" ? activity.showModel : defaults.activity.showModel, showTokens: typeof activity.showTokens === "boolean" ? activity.showTokens : defaults.activity.showTokens, showElapsed: typeof activity.showElapsed === "boolean" ? activity.showElapsed : defaults.activity.showElapsed, showInterruptHint: - typeof activity.showInterruptHint === "boolean" ? activity.showInterruptHint : defaults.activity.showInterruptHint, + typeof activity.showInterruptHint === "boolean" + ? activity.showInterruptHint + : defaults.activity.showInterruptHint, messages: asMessages(activity.messages, defaults.activity.messages), mascot: asMascot(activity.mascot, defaults.activity.mascot), }, compaction: { style: asCompactionStyle(compaction.style, defaults.compaction.style), - showProgress: typeof compaction.showProgress === "boolean" ? compaction.showProgress : defaults.compaction.showProgress, + showProgress: + typeof compaction.showProgress === "boolean" ? compaction.showProgress : defaults.compaction.showProgress, allowScratchpad: - typeof compaction.allowScratchpad === "boolean" ? compaction.allowScratchpad : defaults.compaction.allowScratchpad, + typeof compaction.allowScratchpad === "boolean" + ? compaction.allowScratchpad + : defaults.compaction.allowScratchpad, arcade: asCompactionArcade(compaction.arcade, defaults.compaction.arcade), }, symbols: { - assistantDone: typeof symbols.assistantDone === "string" && symbols.assistantDone ? symbols.assistantDone : defaults.symbols.assistantDone, + assistantDone: + typeof symbols.assistantDone === "string" && symbols.assistantDone + ? symbols.assistantDone + : defaults.symbols.assistantDone, }, } } @@ -361,18 +377,23 @@ export function reasoningPreview(text: string, maxChars = 1200, maxLines = 8) { return { text: `${clipped}…`, truncated: true } } -export function reasoningViewportMaxHeight(terminalHeight: number, input?: { min?: number; max?: number; ratio?: number }) { +export function reasoningViewportMaxHeight( + terminalHeight: number, + input?: { min?: number; max?: number; ratio?: number }, +) { const min = Math.max(1, Math.floor(input?.min ?? 4)) const max = Math.max(min, Math.floor(input?.max ?? 14)) const ratio = input?.ratio ?? 0.32 return Math.max(min, Math.min(max, Math.floor(Math.max(1, terminalHeight) * ratio))) } -export function shouldShowToolContinuation(input: { - finish?: string - terminal: boolean - activeTool: boolean -}) { +export function reasoningViewportHeight(content: string, maxHeight: number) { + const boundedMaxHeight = Math.max(1, Math.floor(maxHeight)) + const contentLines = content.split(/\r?\n/).length + return Math.max(1, Math.min(boundedMaxHeight, contentLines)) +} + +export function shouldShowToolContinuation(input: { finish?: string; terminal: boolean; activeTool: boolean }) { if (input.terminal || !input.activeTool) return false return input.finish === "tool-calls" || input.finish === "unknown" } @@ -382,16 +403,21 @@ export function toolContinuationActivity(input: { status: string; tool?: string if (name === "question" || name.includes("ask_user")) return "Waiting for answer..." if (name.includes("upload")) return "Uploading..." if (name.includes("download")) return "Downloading..." - if (name.includes("web") || name.includes("fetch") || name.includes("browser") || name.includes("chrome")) return "Browsing..." - if (name.includes("install") || name.includes("pnpm") || name.includes("npm") || name.includes("bun")) return "Installing..." - if (name.includes("test") || name.includes("typecheck") || name.includes("lint") || name.includes("build")) return "Testing..." + if (name.includes("web") || name.includes("fetch") || name.includes("browser") || name.includes("chrome")) + return "Browsing..." + if (name.includes("install") || name.includes("pnpm") || name.includes("npm") || name.includes("bun")) + return "Installing..." + if (name.includes("test") || name.includes("typecheck") || name.includes("lint") || name.includes("build")) + return "Testing..." if (name.includes("patch") || name.includes("diff")) return "Patching..." if (name.includes("edit") || name.includes("write") || name.includes("update")) return "Editing..." if (name.includes("read") || name.includes("open") || name.includes("cat")) return "Reading..." - if (name.includes("search") || name.includes("grep") || name.includes("glob") || name.includes("list")) return "Searching..." + if (name.includes("search") || name.includes("grep") || name.includes("glob") || name.includes("list")) + return "Searching..." if (name.includes("plan") || name.includes("spec") || name.includes("review")) return "Planning..." if (name === "task" || name.includes("subagent")) return "Waiting for subagents..." - if (name.includes("bash") || name.includes("shell") || name.includes("exec") || name.includes("command")) return "Running command..." + if (name.includes("bash") || name.includes("shell") || name.includes("exec") || name.includes("command")) + return "Running command..." if (input.status === "pending") return "Generating..." return "Thinking..." } @@ -424,7 +450,10 @@ function cleanPreviewLine(line: string) { } function isOnlyCompactionHeading(line: string) { - const clean = cleanPreviewLine(line).replace(/[:.]+$/, "").trim().toLowerCase() + const clean = cleanPreviewLine(line) + .replace(/[:.]+$/, "") + .trim() + .toLowerCase() return [ "goal", "summary", diff --git a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index db711d04..d445aeef 100644 --- a/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/src/mendcode/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -178,9 +178,10 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) { yield* SessionError.mapStorageNotFound(session.get(ctx.payload.sourceSessionID)) yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) - return yield* agentCommandSvc - .create({ targetSessionID: ctx.params.sessionID, ...ctx.payload }) - .pipe(Effect.catchIf(NotFoundError.isInstance, (error) => Effect.fail(ApiError.notFound(error.data.message)))) + return yield* agentCommandSvc.create({ targetSessionID: ctx.params.sessionID, ...ctx.payload }).pipe( + Effect.catchIf(NotFoundError.isInstance, (error) => Effect.fail(ApiError.notFound(error.data.message))), + Effect.catchTag("AgentCommandInvalidTargetError", () => Effect.fail(new HttpApiError.BadRequest({}))), + ) }) const agentCommandPatch = Effect.fn("SessionHttpApi.agentCommandPatch")(function* (ctx: { @@ -188,7 +189,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", payload: typeof AgentCommandUpdatePayload.Type }) { yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) - return yield* agentCommandSvc + const updated = yield* agentCommandSvc .update({ id: ctx.params.commandID, targetSessionID: ctx.params.sessionID, ...ctx.payload }) .pipe( Effect.catchIf(NotFoundError.isInstance, (error) => Effect.fail(ApiError.notFound(error.data.message))), @@ -204,6 +205,10 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", ), ), ) + if (updated.type === "peer_message" && updated.state === "accepted") { + yield* promptSvc.wakePeerDelivery(updated.targetSessionID) + } + return updated }) const backgroundRegister = Effect.fn("SessionHttpApi.backgroundRegister")(function* (ctx: { diff --git a/src/mendcode/packages/opencode/src/server/routes/instance/session.ts b/src/mendcode/packages/opencode/src/server/routes/instance/session.ts index 1508e859..770d5423 100644 --- a/src/mendcode/packages/opencode/src/server/routes/instance/session.ts +++ b/src/mendcode/packages/opencode/src/server/routes/instance/session.ts @@ -442,6 +442,16 @@ export const SessionRoutes = lazy(() => return yield* command.create({ targetSessionID, ...body }) }) } catch (error) { + if (error instanceof AgentCommand.InvalidTargetError) { + return c.json( + { + success: false as const, + data: null, + errors: [{ name: error.name, message: error.message }], + }, + { status: 400 }, + ) + } if (error instanceof NotFoundError) return c.json(error.toObject(), { status: 404 }) if (error instanceof HTTPException) throw error throw error @@ -475,8 +485,13 @@ export const SessionRoutes = lazy(() => const body = c.req.valid("json") const session = yield* Session.Service const command = yield* AgentCommand.Service + const prompt = yield* SessionPrompt.Service yield* session.get(params.sessionID) - return yield* command.update({ id: params.commandID, targetSessionID: params.sessionID, ...body }) + const updated = yield* command.update({ id: params.commandID, targetSessionID: params.sessionID, ...body }) + if (updated.type === "peer_message" && updated.state === "accepted") { + yield* prompt.wakePeerDelivery(updated.targetSessionID) + } + return updated }) } catch (error) { if (error instanceof AgentCommand.InvalidStateTransitionError) { diff --git a/src/mendcode/packages/opencode/src/session/agent-command-policy.ts b/src/mendcode/packages/opencode/src/session/agent-command-policy.ts index 64c51dc4..86562a1c 100644 --- a/src/mendcode/packages/opencode/src/session/agent-command-policy.ts +++ b/src/mendcode/packages/opencode/src/session/agent-command-policy.ts @@ -3,9 +3,15 @@ import { withStatics } from "@/util/schema" import { Schema } from "effect" import type { Session } from "./session" -export const CommandType = Schema.Literals(["request_summary", "rename", "tag", "pause_after_turn", "stop", "send_message"]).pipe( - withStatics((s) => ({ zod: zod(s) })), -) +export const CommandType = Schema.Literals([ + "request_summary", + "rename", + "tag", + "pause_after_turn", + "stop", + "send_message", + "peer_message", +]).pipe(withStatics((s) => ({ zod: zod(s) }))) export type CommandType = Schema.Schema.Type export const Decision = Schema.Literals(["safe_auto", "same_workspace", "approval_required", "denied"]).pipe( @@ -55,18 +61,28 @@ export function permissionsFor(type: CommandType) { if (type === "pause_after_turn") return ["session.control.pause_after_turn"] if (type === "stop") return ["session.control.stop"] if (type === "send_message") return ["session.message.send"] + if (type === "peer_message") return [] return ["agent_view.metadata.patch"] } function sameWorkspace(input: { source?: SessionLike; target?: SessionLike }) { if (!input.source || !input.target) return false if (input.source.workspaceID || input.target.workspaceID) { - return input.source.workspaceID !== undefined && input.target.workspaceID !== undefined && input.source.workspaceID === input.target.workspaceID + return ( + input.source.workspaceID !== undefined && + input.target.workspaceID !== undefined && + input.source.workspaceID === input.target.workspaceID + ) } return input.source.directory === input.target.directory } -export function evaluate(input: { type: CommandType; source?: SessionLike; target?: SessionLike; ownership?: OwnershipLike }): Info { +export function evaluate(input: { + type: CommandType + source?: SessionLike + target?: SessionLike + ownership?: OwnershipLike +}): Info { if (input.type === "request_summary") { return { decision: "safe_auto", @@ -81,6 +97,20 @@ export function evaluate(input: { type: CommandType; source?: SessionLike; targe reason: "Execution-changing commands require explicit target approval before they can run.", } } + if (input.type === "peer_message") { + if (sameWorkspace(input)) { + return { + decision: "same_workspace", + permissions: [], + reason: "Peer text is non-authorizing; the target must explicitly accept it before delivery.", + } + } + return { + decision: "approval_required", + permissions: [], + reason: "Peer text lacks same-workspace context, so the target must explicitly accept it before delivery.", + } + } if (sameWorkspace(input)) { return { decision: "same_workspace", @@ -128,6 +158,12 @@ export function matrix(): MatrixItem[] { permissions: permissionsFor("send_message"), reason: "Requires explicit target approval before injecting a new user prompt.", }, + { + type: "peer_message", + decision: "same_workspace", + permissions: [], + reason: "Non-authorizing peer text is held until the target explicitly accepts delivery.", + }, ] } diff --git a/src/mendcode/packages/opencode/src/session/agent-command.ts b/src/mendcode/packages/opencode/src/session/agent-command.ts index 5ac36eff..671506f2 100644 --- a/src/mendcode/packages/opencode/src/session/agent-command.ts +++ b/src/mendcode/packages/opencode/src/session/agent-command.ts @@ -12,14 +12,26 @@ import { AgentViewMetadata } from "./agent-view-metadata" import { AgentCommandTable, BackgroundSessionTable, SessionTable } from "./session.sql" import { AgentCommandID, SessionID } from "./schema" -export const CommandType = Schema.Literals(["request_summary", "rename", "tag", "pause_after_turn", "stop", "send_message"]).pipe( - withStatics((s) => ({ zod: zod(s) })), -) +export const CommandType = Schema.Literals([ + "request_summary", + "rename", + "tag", + "pause_after_turn", + "stop", + "send_message", + "peer_message", +]).pipe(withStatics((s) => ({ zod: zod(s) }))) export type CommandType = Schema.Schema.Type -export const State = Schema.Literals(["pending", "accepted", "running", "completed", "rejected", "failed", "expired"]).pipe( - withStatics((s) => ({ zod: zod(s) })), -) +export const State = Schema.Literals([ + "pending", + "accepted", + "running", + "completed", + "rejected", + "failed", + "expired", +]).pipe(withStatics((s) => ({ zod: zod(s) }))) export type State = Schema.Schema.Type const RequestSummaryPayload = Schema.Struct({ @@ -40,8 +52,20 @@ const StopPayload = Schema.Struct({ const SendMessagePayload = Schema.Struct({ text: Schema.String, }) +const PeerMessagePayload = Schema.Struct({ + text: Schema.String, + sourceTitle: Schema.optional(Schema.String), +}) -const Payload = Schema.Union([RequestSummaryPayload, RenamePayload, TagPayload, PauseAfterTurnPayload, StopPayload, SendMessagePayload]) +const Payload = Schema.Union([ + RequestSummaryPayload, + RenamePayload, + TagPayload, + PauseAfterTurnPayload, + StopPayload, + SendMessagePayload, + PeerMessagePayload, +]) type Payload = Schema.Schema.Type export const Create = Schema.Union([ @@ -81,6 +105,12 @@ export const Create = Schema.Union([ payload: SendMessagePayload, expiresAt: Schema.optional(NonNegativeInt), }), + Schema.Struct({ + sourceSessionID: SessionID, + type: Schema.Literal("peer_message"), + payload: Schema.Struct({ text: Schema.String }), + expiresAt: Schema.optional(NonNegativeInt), + }), ]) .annotate({ identifier: "AgentCommandCreate" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) @@ -118,6 +148,7 @@ export const Info = Schema.Union([ Schema.Struct({ ...InfoBase, type: Schema.Literal("pause_after_turn"), payload: PauseAfterTurnPayload }), Schema.Struct({ ...InfoBase, type: Schema.Literal("stop"), payload: StopPayload }), Schema.Struct({ ...InfoBase, type: Schema.Literal("send_message"), payload: SendMessagePayload }), + Schema.Struct({ ...InfoBase, type: Schema.Literal("peer_message"), payload: PeerMessagePayload }), ]) .annotate({ identifier: "AgentCommand" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) @@ -151,6 +182,18 @@ export class InvalidStateTransitionError extends Schema.TaggedErrorClass()( + "AgentCommandInvalidTargetError", + { + sourceSessionID: SessionID, + targetSessionID: SessionID, + }, +) { + override get message() { + return "Peer messages require a different target session." + } +} + export const Event = { Created: BusEvent.define( "agent_command.created", @@ -206,14 +249,17 @@ function toPolicyOwnership(input?: BackgroundRowLike) { export interface Interface { readonly get: (id: AgentCommandID) => Effect.Effect> readonly list: (input?: ListInput) => Effect.Effect - readonly create: (input: CreateInput) => Effect.Effect> - readonly update: (input: UpdateInput) => Effect.Effect | InvalidStateTransitionError> + readonly create: (input: CreateInput) => Effect.Effect | InvalidTargetError> + readonly update: ( + input: UpdateInput, + ) => Effect.Effect | InvalidStateTransitionError> } export class Service extends Context.Service()("@opencode/AgentCommand") {} const terminalStates = new Set(["completed", "rejected", "failed", "expired"]) const defaultCommandTTL = 30 * 60 * 1_000 +const maxPeerMessageTTL = 24 * 60 * 60 * 1_000 const maxPendingPerTarget = 3 const transitions: Record = { pending: ["accepted", "running", "rejected", "expired"], @@ -243,11 +289,21 @@ function normalizeTags(value: readonly string[]) { .slice(0, 20) } -function payloadFor(input: Create): Payload { +function payloadFor(input: Create, sourceTitle?: string): Payload { if (input.type === "request_summary") return { instructions: normalizeString(input.payload?.instructions, 1_000) } - if (input.type === "rename") return { title: normalizeString(input.payload.title, 120) ?? input.payload.title.trim().slice(0, 120) } + if (input.type === "rename") + return { title: normalizeString(input.payload.title, 120) ?? input.payload.title.trim().slice(0, 120) } if (input.type === "tag") return { tags: normalizeTags(input.payload.tags) } - if (input.type === "send_message") return { text: normalizeString(input.payload.text, 4_000) ?? input.payload.text.trim().slice(0, 4_000) } + if (input.type === "send_message") + return { text: normalizeString(input.payload.text, 4_000) ?? input.payload.text.trim().slice(0, 4_000) } + if (input.type === "peer_message") { + const text = normalizeString(input.payload.text, 4_000) + if (!text) throw new Error("Peer message text cannot be empty.") + return { + text, + sourceTitle: normalizeString(sourceTitle, 120), + } + } return { reason: normalizeString(input.payload?.reason, 1_000) } } @@ -298,7 +354,10 @@ function commandProjectID(row: Pick expireRow(row, now)) for (const row of expired) { Database.use((db) => - db.update(AgentCommandTable) + db + .update(AgentCommandTable) .set({ state: row.state, time_updated: row.time_updated, data: row.data }) .where(eq(AgentCommandTable.id, row.id)) .run(), @@ -373,33 +436,56 @@ export const layer = Layer.effect( const get = Effect.fn("AgentCommand.get")(function* (id: AgentCommandID) { yield* expireDueCommands() const row = Database.use((db) => db.select().from(AgentCommandTable).where(eq(AgentCommandTable.id, id)).get()) - if (!row || commandProjectID(row) !== (yield* InstanceState.context).project.id) return yield* Effect.fail(notFound(id)) - const source = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, row.source_session_id)).get()) - const target = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, row.target_session_id)).get()) - const targetBackground = Database.use((db) => db.select().from(BackgroundSessionTable).where(eq(BackgroundSessionTable.session_id, row.target_session_id)).get()) + if (!row || commandProjectID(row) !== (yield* InstanceState.context).project.id) + return yield* Effect.fail(notFound(id)) + const source = Database.use((db) => + db.select().from(SessionTable).where(eq(SessionTable.id, row.source_session_id)).get(), + ) + const target = Database.use((db) => + db.select().from(SessionTable).where(eq(SessionTable.id, row.target_session_id)).get(), + ) + const targetBackground = Database.use((db) => + db + .select() + .from(BackgroundSessionTable) + .where(eq(BackgroundSessionTable.session_id, row.target_session_id)) + .get(), + ) return fromRow(row, { source, target, targetBackground }) }) - const list = Effect.fn("AgentCommand.list")(function* (input?: ListInput) { - yield* expireDueCommands() - const currentProjectID = (yield* InstanceState.context).project.id - const scopedProjectID = input?.sourceSessionID || input?.targetSessionID - ? Database.use((db) => - db - .select({ projectID: SessionTable.project_id }) - .from(SessionTable) - .where(eq(SessionTable.id, input.sourceSessionID ?? input.targetSessionID!)) - .get()?.projectID, + const list = Effect.fn("AgentCommand.list")(function* (input?: ListInput) { + yield* expireDueCommands() + const currentProjectID = (yield* InstanceState.context).project.id + const scopedProjectID = + input?.sourceSessionID || input?.targetSessionID + ? Database.use( + (db) => + db + .select({ projectID: SessionTable.project_id }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sourceSessionID ?? input.targetSessionID!)) + .get()?.projectID, ) : currentProjectID - if (!scopedProjectID || scopedProjectID !== currentProjectID) return [] - return Database.use((db) => db.select().from(AgentCommandTable).all()) - + if (!scopedProjectID || scopedProjectID !== currentProjectID) return [] + const rows = Database.use((db) => db.select().from(AgentCommandTable).all()) + return rows .filter((row) => commandProjectID(row) === scopedProjectID) .map((row) => { - const source = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, row.source_session_id)).get()) - const target = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, row.target_session_id)).get()) - const targetBackground = Database.use((db) => db.select().from(BackgroundSessionTable).where(eq(BackgroundSessionTable.session_id, row.target_session_id)).get()) + const source = Database.use((db) => + db.select().from(SessionTable).where(eq(SessionTable.id, row.source_session_id)).get(), + ) + const target = Database.use((db) => + db.select().from(SessionTable).where(eq(SessionTable.id, row.target_session_id)).get(), + ) + const targetBackground = Database.use((db) => + db + .select() + .from(BackgroundSessionTable) + .where(eq(BackgroundSessionTable.session_id, row.target_session_id)) + .get(), + ) return fromRow(row, { source, target, targetBackground }) }) .filter((info) => matchesInput(info, input)) @@ -412,16 +498,23 @@ export const layer = Layer.effect( const now = Date.now() const source = db.select().from(SessionTable).where(eq(SessionTable.id, input.sourceSessionID)).get() const target = db.select().from(SessionTable).where(eq(SessionTable.id, input.targetSessionID)).get() - const targetBackground = db.select().from(BackgroundSessionTable).where(eq(BackgroundSessionTable.session_id, input.targetSessionID)).get() + const targetBackground = db + .select() + .from(BackgroundSessionTable) + .where(eq(BackgroundSessionTable.session_id, input.targetSessionID)) + .get() if (!source) return sessionNotFound(input.sourceSessionID) if (!target || source.project_id !== target.project_id) return sessionNotFound(input.targetSessionID) + if (input.type === "peer_message" && source.id === target.id) { + return new InvalidTargetError({ sourceSessionID: source.id, targetSessionID: target.id }) + } const policy = AgentCommandPolicy.evaluate({ type: input.type, source: toPolicySession(source), target: toPolicySession(target), ownership: toPolicyOwnership(targetBackground), }) - const payload = payloadFor(input) + const payload = payloadFor(input, source.title) const duplicate = db .select() .from(AgentCommandTable) @@ -441,7 +534,9 @@ export const layer = Layer.effect( .where(eq(AgentCommandTable.target_session_id, input.targetSessionID)) .all() .filter((row) => row.state === "pending" && !commandExpired(row, now)).length - const expiresAt = input.expiresAt ?? now + defaultCommandTTL + const requestedExpiresAt = input.expiresAt ?? now + defaultCommandTTL + const expiresAt = + input.type === "peer_message" ? Math.min(requestedExpiresAt, now + maxPeerMessageTTL) : requestedExpiresAt const overLimit = pendingForTarget >= maxPendingPerTarget const row = { id: AgentCommandID.ascending(), @@ -456,14 +551,18 @@ export const layer = Layer.effect( permissions: policy.permissions, policy, expiresAt, - ...(overLimit ? { error: `Target already has ${maxPendingPerTarget} pending commands; wait for the worker to accept or reject one first.` } : {}), + ...(overLimit + ? { + error: `Target already has ${maxPendingPerTarget} pending commands; wait for the worker to accept or reject one first.`, + } + : {}), ...(expiresAt <= now ? { error: "Command expired before it could be queued." } : {}), } satisfies Data, } db.insert(AgentCommandTable).values(row).run() return { info: fromRow(row, { source, target, targetBackground }), created: true } }) - if (result instanceof NotFoundError) return yield* Effect.fail(result) + if (result instanceof NotFoundError || result instanceof InvalidTargetError) return yield* Effect.fail(result) if (result.created) yield* bus.publish(Event.Created, eventPayload(result.info)) return result.info }) @@ -473,17 +572,26 @@ export const layer = Layer.effect( const projectID = (yield* InstanceState.context).project.id const info = Database.transaction((db) => { const row = db.select().from(AgentCommandTable).where(eq(AgentCommandTable.id, input.id)).get() - if (!row || (input.targetSessionID && row.target_session_id !== input.targetSessionID)) return notFound(input.id) + if (!row || (input.targetSessionID && row.target_session_id !== input.targetSessionID)) + return notFound(input.id) if (commandProjectID(row) !== projectID) return notFound(input.id) const state = input.state ?? row.state if (!canTransition(row.state, state)) { return new InvalidStateTransitionError({ commandID: input.id, from: row.state, to: state }) } const needsPolicyRefresh = policyNeedsRefresh(row.data.policy) - const source = needsPolicyRefresh ? db.select().from(SessionTable).where(eq(SessionTable.id, row.source_session_id)).get() : undefined - const target = needsPolicyRefresh ? db.select().from(SessionTable).where(eq(SessionTable.id, row.target_session_id)).get() : undefined + const source = needsPolicyRefresh + ? db.select().from(SessionTable).where(eq(SessionTable.id, row.source_session_id)).get() + : undefined + const target = needsPolicyRefresh + ? db.select().from(SessionTable).where(eq(SessionTable.id, row.target_session_id)).get() + : undefined const targetBackground = needsPolicyRefresh - ? db.select().from(BackgroundSessionTable).where(eq(BackgroundSessionTable.session_id, row.target_session_id)).get() + ? db + .select() + .from(BackgroundSessionTable) + .where(eq(BackgroundSessionTable.session_id, row.target_session_id)) + .get() : undefined const next = { ...row, diff --git a/src/mendcode/packages/opencode/src/session/loop-runner.ts b/src/mendcode/packages/opencode/src/session/loop-runner.ts index f43890f9..5b6f88dd 100644 --- a/src/mendcode/packages/opencode/src/session/loop-runner.ts +++ b/src/mendcode/packages/opencode/src/session/loop-runner.ts @@ -9,7 +9,11 @@ import { InstanceState } from "@/effect/instance-state" import { WorkflowRunner } from "@/session/workflow-runner" import { WorkflowService } from "@/session/workflow-service" import { fingerprintWorkspace } from "@/session/completion-auditor" -import { compileCompletionCriteria, type CompletionAuditReceipt, type CompletionProgress } from "@/session/completion-contract" +import { + compileCompletionCriteria, + type CompletionAuditReceipt, + type CompletionProgress, +} from "@/session/completion-contract" import { completionValidationCommandAllowed, runCompletionValidationCommand } from "@/session/completion-validation" export const TickResult = Schema.Struct({ @@ -21,7 +25,9 @@ export const TickResult = Schema.Struct({ export type TickResult = Types.DeepMutable> export interface Interface { - readonly runOne: (input: RunOneInput) => Effect.Effect + readonly runOne: ( + input: RunOneInput, + ) => Effect.Effect readonly runDue: (input?: { now?: number limit?: number @@ -107,8 +113,10 @@ function loopMemoryPrompt(workflow: LoopWorkflow.Info) { function budgetSemantics(workflow: LoopWorkflow.Info) { const mode = workflow.spec.budgetMode ?? "legacy" if (mode === "fixed") return "fixed: run exactly until the iteration cap unless blocked or stopped." - if (mode === "unbounded-monitor") return "unbounded-monitor: continue monitoring until a stop condition, blocker, or user stop." - if (mode === "max-goal") return "max-goal: maxTurns is a budget cap, not a work plan; finish as soon as the goal is complete and verified." + if (mode === "unbounded-monitor") + return "unbounded-monitor: continue monitoring until a stop condition, blocker, or user stop." + if (mode === "max-goal") + return "max-goal: maxTurns is a budget cap, not a work plan; finish as soon as the goal is complete and verified." return "legacy: preserve existing loop behavior; use stop conditions and budget carefully." } @@ -130,9 +138,12 @@ function checkpointGuidance(workflow: LoopWorkflow.Info) { function workspacePolicyPrompt(workflow: LoopWorkflow.Info) { const mode = workflow.spec.workspace?.mode ?? "in-place" - if (mode === "read-only") return "Workspace policy: read-only. Inspect and report only; do not edit files or run mutating shell commands." - if (mode === "per-loop-worktree") return "Workspace policy: per-loop-worktree. Use the assigned loop workspace metadata when available; do not create, promote, or clean worktrees yourself unless explicitly instructed." - if (mode === "per-run-worktree") return "Workspace policy: per-run-worktree. Use the assigned run workspace metadata when available; do not create, promote, or clean worktrees yourself unless explicitly instructed." + if (mode === "read-only") + return "Workspace policy: read-only. Inspect and report only; do not edit files or run mutating shell commands." + if (mode === "per-loop-worktree") + return "Workspace policy: per-loop-worktree. Use the assigned loop workspace metadata when available; do not create, promote, or clean worktrees yourself unless explicitly instructed." + if (mode === "per-run-worktree") + return "Workspace policy: per-run-worktree. Use the assigned run workspace metadata when available; do not create, promote, or clean worktrees yourself unless explicitly instructed." return "Workspace policy: in-place. Work in the current project workspace and keep changes minimal and auditable." } @@ -158,7 +169,9 @@ function iterationPrompt(workflow: LoopWorkflow.Info, reason?: string) { workspacePolicyPrompt(workflow), `Budget mode: ${budgetSemantics(workflow)}`, `Remaining iteration budget after this run starts: ${remaining}`, - strategy?.targetTurns ? `Target completion window: aim to complete by about ${strategy.targetTurns} iterations if possible.` : undefined, + strategy?.targetTurns + ? `Target completion window: aim to complete by about ${strategy.targetTurns} iterations if possible.` + : undefined, reserve ? `Reserved verification/recovery turns: ${reserve}` : undefined, `Current phase: ${workflow.phase}`, `Next scheduled wakeup: ${next}`, @@ -166,12 +179,21 @@ function iterationPrompt(workflow: LoopWorkflow.Info, reason?: string) { loopMemoryPrompt(workflow), "", "Completion criteria:", - numberedList(workflow.spec.completionCriteria, "Use the objective, stop conditions, and explicit user constraints as completion criteria."), + numberedList( + workflow.spec.completionCriteria, + "Use the objective, stop conditions, and explicit user constraints as completion criteria.", + ), "", "Success checks:", - numberedList(workflow.spec.successChecks, "Run the most relevant available validation and explain any unavailable checks."), + numberedList( + workflow.spec.successChecks, + "Run the most relevant available validation and explain any unavailable checks.", + ), workflow.spec.validationChecks?.length - ? ["Executable validation checks:", ...workflow.spec.validationChecks.map((check) => `- ${check.id}: ${check.command}`)].join("\n") + ? [ + "Executable validation checks:", + ...workflow.spec.validationChecks.map((check) => `- ${check.id}: ${check.command}`), + ].join("\n") : undefined, "", `Gates: ${gates}`, @@ -225,7 +247,15 @@ function loopIterationTools(reportOnly: boolean): Record { } const reportOnlyApprovalGates = ["edit", "write", "apply_patch", "shell", "subagent"] -const editAllowedApprovalGates = ["push", "merge", "release", "version-bump", "external-send", "destructive-shell", "broad-refactor"] +const editAllowedApprovalGates = [ + "push", + "merge", + "release", + "version-bump", + "external-send", + "destructive-shell", + "broad-refactor", +] function workflowIsReportOnly(workflow: LoopWorkflow.Info) { if (workflow.spec.workspace?.mode === "read-only") return true @@ -243,16 +273,31 @@ function workflowExplicitlyAllowsEdits(workflow: LoopWorkflow.Info) { function runTriggerFor(workflow: LoopWorkflow.Info): LoopWorkflow.RunTrigger { const mode = workflow.spec.trigger?.mode - if (mode === "interval" || mode === "daily" || mode === "adaptive" || mode === "external-signal" || mode === "self-paced") return mode + if ( + mode === "interval" || + mode === "daily" || + mode === "adaptive" || + mode === "external-signal" || + mode === "self-paced" + ) + return mode return "manual" } -function readinessDecision(workflow: LoopWorkflow.Info): { ready: true } | { ready: false; reason: string; nextWakeup?: number } { +function readinessDecision( + workflow: LoopWorkflow.Info, +): { ready: true } | { ready: false; reason: string; nextWakeup?: number } { if (workflow.state === "blocked" || workflow.state === "needs_input") { - return { ready: false, reason: `Loop is ${workflow.state}; resolve the blocking condition before running it again.` } + return { + ready: false, + reason: `Loop is ${workflow.state}; resolve the blocking condition before running it again.`, + } } if (typeof workflow.policy.maxTurns === "number" && (workflow.metrics.turns ?? 0) >= workflow.policy.maxTurns) { - return { ready: false, reason: `Loop has no remaining iteration budget (${workflow.metrics.turns ?? 0}/${workflow.policy.maxTurns}).` } + return { + ready: false, + reason: `Loop has no remaining iteration budget (${workflow.metrics.turns ?? 0}/${workflow.policy.maxTurns}).`, + } } if (workflow.spec.trigger?.mode === "external-signal" && workflow.phase !== "signal_received") { return { ready: false, reason: "External-signal loop has no pending normalized signal evidence to process." } @@ -276,7 +321,11 @@ function assistantText(message: MessageV2.WithParts) { .join("\n") } -function usageFromMessage(message: MessageV2.WithParts | undefined, workflow: LoopWorkflow.Info, started: number): LoopWorkflow.Usage | undefined { +function usageFromMessage( + message: MessageV2.WithParts | undefined, + workflow: LoopWorkflow.Info, + started: number, +): LoopWorkflow.Usage | undefined { if (!message || message.info.role !== "assistant") return undefined return { providerID: message.info.providerID ?? workflow.spec.model?.providerID, @@ -294,11 +343,28 @@ function usageFromMessage(message: MessageV2.WithParts | undefined, workflow: Lo } } -function incompleteWorkerReason(message: MessageV2.WithParts) { +function incompleteWorkerFailure(message: MessageV2.WithParts): { + reason: string + failureClass: LoopWorkflow.FailureClass +} { if (message.info.role !== "assistant" || !message.info.finish) { - return "Loop worker ended without a terminal finish; retrying the iteration." + const hasToolAttempt = message.parts.some((part) => part.type === "tool") + if (hasToolAttempt) { + return { + reason: + "Loop worker ended after a tool call without a terminal finish. Automatic retry paused because the tool may have changed the workspace; inspect the loop chat and resume explicitly.", + failureClass: "user_input", + } + } + return { + reason: "Loop worker ended without a terminal finish; retrying the iteration.", + failureClass: "transient", + } + } + return { + reason: "Loop worker finished without a parseable LOOP_CHECKPOINT; retrying the iteration.", + failureClass: "transient", } - return "Loop worker finished without a parseable LOOP_CHECKPOINT; retrying the iteration." } function mergeUsage(usages: Array): LoopWorkflow.Usage | undefined { @@ -336,7 +402,10 @@ function parseLoopBlock(text: string, marker: "LOOP_CHECKPOINT" | "LOOP_JUDGMENT const line = rawLine.trim() if (!line) continue if (/^status\s*:/i.test(line)) { - parsed.status = line.replace(/^status\s*:\s*/i, "").trim().toLowerCase() + parsed.status = line + .replace(/^status\s*:\s*/i, "") + .trim() + .toLowerCase() inEvidence = false continue } @@ -380,7 +449,11 @@ function parseCheckpoint(text: string): LoopCheckpoint { if (!block) return {} const statusRaw = block.status const status = - statusRaw === "complete" || statusRaw === "continue" || statusRaw === "needs_input" || statusRaw === "blocked" || statusRaw === "stop" + statusRaw === "complete" || + statusRaw === "continue" || + statusRaw === "needs_input" || + statusRaw === "blocked" || + statusRaw === "stop" ? statusRaw : undefined return { @@ -397,7 +470,11 @@ function parseJudgment(text: string): LoopJudgment { if (!block) return { status: "uncertain", summary: "Evaluator did not return a LOOP_JUDGMENT block." } const statusRaw = block.status const status = - statusRaw === "pass" || statusRaw === "fail" || statusRaw === "uncertain" || statusRaw === "blocked" || statusRaw === "needs_human" + statusRaw === "pass" || + statusRaw === "fail" || + statusRaw === "uncertain" || + statusRaw === "blocked" || + statusRaw === "needs_human" ? statusRaw : "uncertain" return { @@ -414,7 +491,10 @@ function requiresIndependentCompletion(workflow: LoopWorkflow.Info, checkpoint: if (checkpoint.status !== "complete" && checkpoint.status !== "blocked") return false if (workflow.spec.evaluation?.allowWorkerSelfComplete === true) return false if (workflow.spec.evaluation?.confirmation === "next-run") return false - return workflow.spec.evaluation?.mode === "independent" || workflow.spec.evaluation?.requireIndependentForCompletion === true + return ( + workflow.spec.evaluation?.mode === "independent" || + workflow.spec.evaluation?.requireIndependentForCompletion === true + ) } function checkpointGate(checkpoint: LoopCheckpoint): LoopGateResult { @@ -445,10 +525,20 @@ const sensitiveActionPatterns: Array<[string, RegExp]> = [ ] function approvalGate(workflow: LoopWorkflow.Info, checkpoint: LoopCheckpoint): LoopGateResult | undefined { - const corpus = [checkpoint.summary, checkpoint.nextAction, ...(checkpoint.evidence ?? [])].filter((item): item is string => Boolean(item)).join("\n") - const required = new Set([...(workflow.policy.requireApprovalFor ?? []), ...(workflow.spec.approvalPolicy?.requireApprovalFor ?? [])]) - const approved = new Set([...(workflow.policy.approvedActions ?? []), ...(workflow.spec.approvalPolicy?.approvedActions ?? [])]) - const attempted = sensitiveActionPatterns.flatMap(([action, pattern]) => (required.has(action) && pattern.test(corpus) ? [action] : [])) + const corpus = [checkpoint.summary, checkpoint.nextAction, ...(checkpoint.evidence ?? [])] + .filter((item): item is string => Boolean(item)) + .join("\n") + const required = new Set([ + ...(workflow.policy.requireApprovalFor ?? []), + ...(workflow.spec.approvalPolicy?.requireApprovalFor ?? []), + ]) + const approved = new Set([ + ...(workflow.policy.approvedActions ?? []), + ...(workflow.spec.approvalPolicy?.approvedActions ?? []), + ]) + const attempted = sensitiveActionPatterns.flatMap(([action, pattern]) => + required.has(action) && pattern.test(corpus) ? [action] : [], + ) if (!attempted.length) return const missing = attempted.filter((action) => !approved.has(action)) if (missing.length) { @@ -462,16 +552,15 @@ function approvalGate(workflow: LoopWorkflow.Info, checkpoint: LoopCheckpoint): return { id: "approval-policy", status: "pass", - summary: attempted.length ? `Sensitive action approvals present: ${attempted.join(", ")}.` : "No approval-required sensitive action was reported.", + summary: attempted.length + ? `Sensitive action approvals present: ${attempted.join(", ")}.` + : "No approval-required sensitive action was reported.", failureClass: "none", } } function evidenceCorpus(checkpoint: LoopCheckpoint, judgment: LoopJudgment | undefined) { - return [ - ...(checkpoint.evidence ?? []), - ...(judgment?.evidence ?? []), - ] + return [...(checkpoint.evidence ?? []), ...(judgment?.evidence ?? [])] .filter((item): item is string => Boolean(item)) .map((item) => item.toLowerCase()) } @@ -480,10 +569,15 @@ function trimmedNonEmptyStrings(items: readonly string[] | undefined) { return items?.map((item) => item.trim()).filter((item): item is string => item.length > 0) ?? [] } -function successChecksGate(workflow: LoopWorkflow.Info, checkpoint: LoopCheckpoint, judgment: LoopJudgment | undefined): LoopGateResult | undefined { +function successChecksGate( + workflow: LoopWorkflow.Info, + checkpoint: LoopCheckpoint, + judgment: LoopJudgment | undefined, +): LoopGateResult | undefined { const checks = trimmedNonEmptyStrings(workflow.spec.successChecks) if (!checks.length) return - const shouldEvaluate = checkpoint.status === "complete" || (checkpoint.status === "blocked" && judgment?.status === "pass") + const shouldEvaluate = + checkpoint.status === "complete" || (checkpoint.status === "blocked" && judgment?.status === "pass") if (!shouldEvaluate) { return { id: "success-checks", @@ -517,47 +611,58 @@ export function loopValidationCommandAllowed(command: string) { return completionValidationCommandAllowed(command) } -function executeValidationChecks(workflowService: LoopWorkflow.Interface, workflow: LoopWorkflow.Info, run: LoopWorkflow.RunInfo, checkpoint: LoopCheckpoint, directory: string) { +function executeValidationChecks( + workflowService: LoopWorkflow.Interface, + workflow: LoopWorkflow.Info, + run: LoopWorkflow.RunInfo, + checkpoint: LoopCheckpoint, + directory: string, +) { const checks = workflow.spec.validationChecks ?? [] - if (!checks.length || (checkpoint.status !== "complete" && checkpoint.status !== "blocked")) return Effect.succeed([] as LoopGateResult[]) + if (!checks.length || (checkpoint.status !== "complete" && checkpoint.status !== "blocked")) + return Effect.succeed([] as LoopGateResult[]) return Effect.gen(function* () { - return yield* Effect.forEach(checks, (check) => - Effect.gen(function* () { - const command = check.command.trim() - const result: ValidationExecution = yield* runCompletionValidationCommand( - command, - run.workspaceLease?.state === "active" ? run.workspaceLease.path : directory, - Math.max(1_000, Math.min(check.timeoutMs ?? 120_000, 10 * 60_000)), - !workflowIsReportOnly(workflow), - ) - const artifact = yield* workflowService.recordValidation({ - id: workflow.id, - runID: run.id, - checkID: check.id, - command, - status: result.status, - summary: result.summary, - output: result.output, - exitCode: result.exitCode, - durationMs: result.durationMs, - timedOut: result.timedOut, - }).pipe(Effect.orElseSucceed(() => undefined)) - if (!artifact) { + return yield* Effect.forEach( + checks, + (check) => + Effect.gen(function* () { + const command = check.command.trim() + const result: ValidationExecution = yield* runCompletionValidationCommand( + command, + run.workspaceLease?.state === "active" ? run.workspaceLease.path : directory, + Math.max(1_000, Math.min(check.timeoutMs ?? 120_000, 10 * 60_000)), + !workflowIsReportOnly(workflow), + ) + const artifact = yield* workflowService + .recordValidation({ + id: workflow.id, + runID: run.id, + checkID: check.id, + command, + status: result.status, + summary: result.summary, + output: result.output, + exitCode: result.exitCode, + durationMs: result.durationMs, + timedOut: result.timedOut, + }) + .pipe(Effect.orElseSucceed(() => undefined)) + if (!artifact) { + return { + id: `validation:${check.id}`, + status: "blocked", + summary: "Validation result was discarded because the loop run is no longer active.", + failureClass: "environment", + } satisfies LoopGateResult + } return { id: `validation:${check.id}`, - status: "blocked", - summary: "Validation result was discarded because the loop run is no longer active.", - failureClass: "environment", + status: result.status, + summary: result.summary, + failureClass: result.failureClass, + evidenceArtifacts: [artifact.id], } satisfies LoopGateResult - } - return { - id: `validation:${check.id}`, - status: result.status, - summary: result.summary, - failureClass: result.failureClass, - evidenceArtifacts: [artifact.id], - } satisfies LoopGateResult - }), + }), { concurrency: 1 }, ) }) @@ -576,27 +681,27 @@ function rubricRequirementMet(input: { const approval = input.gates.find((gate) => gate.id === "approval-policy") const validationRequired = input.workflow.spec.validationChecks?.length ?? 0 const successRequired = trimmedNonEmptyStrings(input.workflow.spec.successChecks).length > 0 - const validationPassed = validationRequired === 0 || ( - validation.length === validationRequired && - validation.every((gate) => gate.status === "pass") - ) + const validationPassed = + validationRequired === 0 || + (validation.length === validationRequired && validation.every((gate) => gate.status === "pass")) const successPassed = !successRequired || success?.status === "pass" if (requirement === "checkpoint evidence") return Boolean(input.checkpoint.evidence?.length) if (requirement === "success checks") { return validationPassed && successPassed } if (requirement === "success check output" || requirement === "validation output") { - const validationOutputRecorded = validationRequired === 0 || ( - validation.length === validationRequired && - validation.every((gate) => gate.status === "pass" && Boolean(gate.evidenceArtifacts?.length)) - ) - const successEvidenceRecorded = !successRequired || ( - successPassed && Boolean(input.checkpoint.evidence?.length || input.judgment?.evidence?.length) - ) + const validationOutputRecorded = + validationRequired === 0 || + (validation.length === validationRequired && + validation.every((gate) => gate.status === "pass" && Boolean(gate.evidenceArtifacts?.length))) + const successEvidenceRecorded = + !successRequired || + (successPassed && Boolean(input.checkpoint.evidence?.length || input.judgment?.evidence?.length)) return validationOutputRecorded && successEvidenceRecorded } if (requirement === "policy gate status") return !approval || approval.status === "pass" - if (requirement.startsWith("gate:")) return input.gates.find((gate) => gate.id === requirement.slice(5))?.status === "pass" + if (requirement.startsWith("gate:")) + return input.gates.find((gate) => gate.id === requirement.slice(5))?.status === "pass" return [ ...(input.checkpoint.evidence ?? []), ...(input.judgment?.evidence ?? []), @@ -604,12 +709,23 @@ function rubricRequirementMet(input: { ].some((item) => item.toLowerCase().includes(requirement)) } -function evaluateRubric(workflow: LoopWorkflow.Info, checkpoint: LoopCheckpoint, judgment: LoopJudgment | undefined, gates: LoopGateResult[]): LoopWorkflow.RubricResult | undefined { +function evaluateRubric( + workflow: LoopWorkflow.Info, + checkpoint: LoopCheckpoint, + judgment: LoopJudgment | undefined, + gates: LoopGateResult[], +): LoopWorkflow.RubricResult | undefined { const rubric = workflow.spec.rubric - if (!rubric || (checkpoint.status !== "complete" && !(checkpoint.status === "blocked" && judgment?.status === "pass"))) return undefined + if ( + !rubric || + (checkpoint.status !== "complete" && !(checkpoint.status === "blocked" && judgment?.status === "pass")) + ) + return undefined const criteria = (rubric.criteria ?? []).map((criterion) => { const requirements = trimmedNonEmptyStrings(criterion.evidenceRequired) - const evidence = requirements.filter((requirement) => rubricRequirementMet({ requirement, workflow, checkpoint, judgment, gates })) + const evidence = requirements.filter((requirement) => + rubricRequirementMet({ requirement, workflow, checkpoint, judgment, gates }), + ) const passed = requirements.length ? evidence.length === requirements.length : Boolean(checkpoint.evidence?.length || judgment?.evidence?.length) @@ -620,13 +736,20 @@ function evaluateRubric(workflow: LoopWorkflow.Info, checkpoint: LoopCheckpoint, score, maxScore, passed: passed && score >= (criterion.minScore ?? 0), - reason: passed ? "Required runtime evidence is present." : `Missing required evidence: ${requirements.filter((item) => !evidence.includes(item)).join(", ") || "concrete completion evidence"}.`, + reason: passed + ? "Required runtime evidence is present." + : `Missing required evidence: ${requirements.filter((item) => !evidence.includes(item)).join(", ") || "concrete completion evidence"}.`, evidence, weight: Math.max(0, criterion.weight ?? 1), } }) - const gateCorpus = gates.map((gate) => `${gate.id} ${gate.status} ${gate.summary ?? ""}`).join("\n").toLowerCase() - const proposalCorpus = [...(checkpoint.evidence ?? []), ...(judgment?.evidence ?? []), judgment?.summary ?? ""].join("\n").toLowerCase() + const gateCorpus = gates + .map((gate) => `${gate.id} ${gate.status} ${gate.summary ?? ""}`) + .join("\n") + .toLowerCase() + const proposalCorpus = [...(checkpoint.evidence ?? []), ...(judgment?.evidence ?? []), judgment?.summary ?? ""] + .join("\n") + .toLowerCase() const blockers = (rubric.mandatoryBlockers ?? []).map((blocker) => { const normalized = blocker.toLowerCase() const present = /configured validation failed/.test(normalized) @@ -645,13 +768,21 @@ function evaluateRubric(workflow: LoopWorkflow.Info, checkpoint: LoopCheckpoint, } }) const totalWeight = criteria.reduce((sum, criterion) => sum + criterion.weight, 0) - const score = totalWeight > 0 - ? criteria.reduce((sum, criterion) => sum + (criterion.score / criterion.maxScore) * criterion.weight, 0) / totalWeight - : blockers.some((blocker) => blocker.present) ? 0 : 1 + const score = + totalWeight > 0 + ? criteria.reduce((sum, criterion) => sum + (criterion.score / criterion.maxScore) * criterion.weight, 0) / + totalWeight + : blockers.some((blocker) => blocker.present) + ? 0 + : 1 const threshold = Math.max(0, Math.min(1, rubric.passThreshold ?? 0.85)) const blocked = blockers.some((blocker) => blocker.present) return { - status: blocked ? "blocked" : score >= threshold && criteria.every((criterion) => criterion.passed) ? "pass" : "fail", + status: blocked + ? "blocked" + : score >= threshold && criteria.every((criterion) => criterion.passed) + ? "pass" + : "fail", score, threshold, criteria: criteria.map((criterion) => ({ @@ -688,7 +819,8 @@ function evaluatorGate(judgment: LoopJudgment | undefined): LoopGateResult | und return { id: "independent-evaluator", status: "blocked", - summary: judgment.summary ?? `Independent evaluator failed before producing a verdict (${judgment.failureClass}).`, + summary: + judgment.summary ?? `Independent evaluator failed before producing a verdict (${judgment.failureClass}).`, failureClass: judgment.failureClass, } } @@ -701,7 +833,11 @@ function evaluatorGate(judgment: LoopJudgment | undefined): LoopGateResult | und } function classifyEvaluatorFailure(error: string): LoopWorkflow.FailureClass { - if (/\b(timeout|timed out|rate limit|429|econnreset|etimedout|eai_again|network|overloaded|temporar|retry|503|502|504|500)\b/i.test(error)) { + if ( + /\b(timeout|timed out|rate limit|429|econnreset|etimedout|eai_again|network|overloaded|temporar|retry|503|502|504|500)\b/i.test( + error, + ) + ) { return "transient" } return "environment" @@ -711,7 +847,10 @@ function judgmentPrompt(workflow: LoopWorkflow.Info, checkpoint: LoopCheckpoint, const criteria = compileCompletionCriteria( workflow.spec.completionCriteria?.length ? workflow.spec.completionCriteria : [workflow.objective], ) - const successChecks = numberedList(workflow.spec.successChecks, "No explicit success checks were configured; evaluate only the evidence provided.") + const successChecks = numberedList( + workflow.spec.successChecks, + "No explicit success checks were configured; evaluate only the evidence provided.", + ) const rubric = workflow.spec.rubric return [ `Independent loop evaluator: ${workflow.name}`, @@ -737,14 +876,21 @@ function judgmentPrompt(workflow: LoopWorkflow.Info, checkpoint: LoopCheckpoint, "", "Deterministic gate results (authoritative; the judge cannot override failures):", gates.length - ? gates.map((gate) => `- ${gate.id}: ${gate.status} · ${gate.summary ?? "no summary"}${gate.evidenceArtifacts?.length ? ` · artifacts ${gate.evidenceArtifacts.join(", ")}` : ""}`).join("\n") + ? gates + .map( + (gate) => + `- ${gate.id}: ${gate.status} · ${gate.summary ?? "no summary"}${gate.evidenceArtifacts?.length ? ` · artifacts ${gate.evidenceArtifacts.join(", ")}` : ""}`, + ) + .join("\n") : "- no deterministic gate results were recorded", "", "Worker completion proposal (untrusted):", `status: ${checkpoint.status ?? "missing"}`, `summary: ${checkpoint.summary ?? "missing"}`, `confidence: ${checkpoint.confidence ?? "missing"}`, - checkpoint.evidence?.length ? ["evidence:", ...checkpoint.evidence.map((item) => `- ${item}`)].join("\n") : "evidence: none", + checkpoint.evidence?.length + ? ["evidence:", ...checkpoint.evidence.map((item) => `- ${item}`)].join("\n") + : "evidence: none", checkpoint.nextAction ? `next_action: ${checkpoint.nextAction}` : undefined, "", "End your final message with this exact machine-readable block:", @@ -822,7 +968,9 @@ function auditReceipt(input: { const status = input.judgment?.status ?? "uncertain" const evidence = input.judgment?.evidence ?? [] const criteria = compileCompletionCriteria( - input.workflow.spec.completionCriteria?.length ? input.workflow.spec.completionCriteria : [input.workflow.objective], + input.workflow.spec.completionCriteria?.length + ? input.workflow.spec.completionCriteria + : [input.workflow.objective], ) return { generation: input.candidate.generation, @@ -832,7 +980,7 @@ function auditReceipt(input: { const criterionEvidence = evidence.filter((item) => item.toLowerCase().includes(criterion.id.toLowerCase())) return { id: criterion.id, - status: status === "pass" && criterionEvidence.length === 0 ? "uncertain" as const : status, + status: status === "pass" && criterionEvidence.length === 0 ? ("uncertain" as const) : status, summary: criterionEvidence.length ? (input.judgment?.summary ?? `${criterion.id} verified.`) : `No concrete audit evidence was recorded for ${criterion.id}.`, @@ -862,7 +1010,9 @@ function parentCompletionPrompt(workflow: LoopWorkflow.Info, checkpoint: LoopChe `Goal: ${workflow.objective}`, "", `Summary: ${checkpoint.summary ?? workflow.evaluatorReason ?? "Loop reported completion."}`, - checkpoint.evidence?.length ? ["Evidence:", ...checkpoint.evidence.map((item) => `- ${item}`)].join("\n") : undefined, + checkpoint.evidence?.length + ? ["Evidence:", ...checkpoint.evidence.map((item) => `- ${item}`)].join("\n") + : undefined, checkpoint.nextAction ? `Next action from loop: ${checkpoint.nextAction}` : undefined, "", "Review this loop result and decide the next useful step for the user. Do not create a new loop, push, merge, release, or run broad changes unless the user explicitly asks.", @@ -918,12 +1068,12 @@ export const layer = Layer.effect( summary: skipped.evaluatorReason ?? readiness.reason, } satisfies TickResult } - const latestCompletionRun = before.spec.evaluation?.confirmation === "next-run" - ? (yield* workflow.snapshot(id, 20)).runs.find((item) => item.completion !== undefined) - : undefined - const pendingCompletionRun = latestCompletionRun?.completion?.status === "candidate" - ? latestCompletionRun - : undefined + const latestCompletionRun = + before.spec.evaluation?.confirmation === "next-run" + ? (yield* workflow.snapshot(id, 20)).runs.find((item) => item.completion !== undefined) + : undefined + const pendingCompletionRun = + latestCompletionRun?.completion?.status === "candidate" ? latestCompletionRun : undefined const leaseHolder = `loop-runner:${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}` const run = yield* workflow.startRun({ id, trigger: input.trigger ?? runTriggerFor(before), leaseHolder, now }) if (run.lease?.holder !== leaseHolder) { @@ -936,14 +1086,16 @@ export const layer = Layer.effect( } const current = yield* workflow.get(id, now) if (!current.rootSessionID) { - return yield* workflow.failRun({ id, runID: run.id, error: "Loop has no root session after activation.", now }).pipe( - Effect.map((failed) => ({ - workflowID: id, - runID: failed.id, - state: "failed" as const, - summary: failed.evaluatorReason ?? "Loop run failed.", - })), - ) + return yield* workflow + .failRun({ id, runID: run.id, error: "Loop has no root session after activation.", now }) + .pipe( + Effect.map((failed) => ({ + workflowID: id, + runID: failed.id, + state: "failed" as const, + summary: failed.evaluatorReason ?? "Loop run failed.", + })), + ) } if (pendingCompletionRun?.completion?.status === "candidate") { const candidate = { @@ -965,10 +1117,15 @@ export const layer = Layer.effect( ? { ...run, workspaceLease: pendingCompletionRun.workspaceLease } : run const validationGates = yield* executeValidationChecks(workflow, current, validationRun, checkpoint, directory) - const priorGates = (pendingCompletionRun.gateResults ?? []).filter((gate) => gate.id !== "independent-evaluator") + const priorGates = (pendingCompletionRun.gateResults ?? []).filter( + (gate) => gate.id !== "independent-evaluator", + ) const preJudgeGates = Array.from( new Map( - [...priorGates, ...validationGates, workspaceFingerprintGate(fingerprintBeforeResult)].map((gate) => [gate.id, gate]), + [...priorGates, ...validationGates, workspaceFingerprintGate(fingerprintBeforeResult)].map((gate) => [ + gate.id, + gate, + ]), ).values(), ) let judgment: LoopJudgment @@ -983,10 +1140,7 @@ export const layer = Layer.effect( confidence: "high", failureClass: "environment", } - } else if ( - candidate.candidateFingerprint && - fingerprintBeforeResult.value !== candidate.candidateFingerprint - ) { + } else if (candidate.candidateFingerprint && fingerprintBeforeResult.value !== candidate.candidateFingerprint) { judgment = { status: "fail", summary: "Workspace changed after the completion candidate was recorded.", @@ -1008,16 +1162,18 @@ export const layer = Layer.effect( model: promptModel(current), variant: current.spec.model?.variant, tools: loopIterationTools(true), - parts: [{ - type: "text", - text: [ - judgmentPrompt(current, checkpoint, preJudgeGates), - "", - `This is a fresh terminal audit iteration for candidate generation ${candidate.generation}.`, - `Inspect the current workspace at ${directory}; do not rely only on the worker summary.`, - "Verify every completion criterion against current files and recorded deterministic gates.", - ].join("\n"), - }], + parts: [ + { + type: "text", + text: [ + judgmentPrompt(current, checkpoint, preJudgeGates), + "", + `This is a fresh terminal audit iteration for candidate generation ${candidate.generation}.`, + `Inspect the current workspace at ${directory}; do not rely only on the worker summary.`, + "Verify every completion criterion against current files and recorded deterministic gates.", + ].join("\n"), + }, + ], }) .pipe( Effect.map((message) => ({ message, judgment: parseJudgment(assistantText(message)) })), @@ -1037,9 +1193,9 @@ export const layer = Layer.effect( const evaluatorMessage = "message" in evaluatorResult ? evaluatorResult.message : undefined evaluatorUsage = usageFromMessage(evaluatorMessage, current, evaluatorStarted) judgment = evaluatorResult.judgment - const auditMessages = yield* sessions.messages({ sessionID: evaluatorSession.id, view: "full" }).pipe( - Effect.catchCause(() => Effect.succeed([])), - ) + const auditMessages = yield* sessions + .messages({ sessionID: evaluatorSession.id, view: "full" }) + .pipe(Effect.catchCause(() => Effect.succeed([]))) inspectionGate = auditInspectionGate(evaluatorMessage ? [...auditMessages, evaluatorMessage] : auditMessages) } const fingerprintAfterResult = yield* Effect.promise(() => fingerprintWorkspace(directory)) @@ -1058,23 +1214,47 @@ export const layer = Layer.effect( } } const fingerprintsAvailable = fingerprintAfterResult.status === "ok" && fingerprintBeforeResult.status === "ok" - const fingerprintStable = fingerprintsAvailable && fingerprintAfterResult.value === fingerprintBeforeResult.value - const fingerprintMatchesCandidate = fingerprintsAvailable && ( - !candidate.candidateFingerprint || fingerprintBeforeResult.value === candidate.candidateFingerprint - ) - const fingerprintGate: LoopGateResult = fingerprintStable && fingerprintMatchesCandidate - ? { id: "workspace-fingerprint", status: "pass", summary: "Workspace fingerprint matched the candidate and stayed stable throughout the audit.", failureClass: "none" } - : fingerprintsAvailable - ? { id: "workspace-fingerprint", status: "fail", summary: "Workspace fingerprint changed after completion was proposed or during its audit.", failureClass: "quality" } - : { id: "workspace-fingerprint", status: "blocked", summary: fingerprintAfterResult.summary, failureClass: "environment" } + const fingerprintStable = + fingerprintsAvailable && fingerprintAfterResult.value === fingerprintBeforeResult.value + const fingerprintMatchesCandidate = + fingerprintsAvailable && + (!candidate.candidateFingerprint || fingerprintBeforeResult.value === candidate.candidateFingerprint) + const fingerprintGate: LoopGateResult = + fingerprintStable && fingerprintMatchesCandidate + ? { + id: "workspace-fingerprint", + status: "pass", + summary: "Workspace fingerprint matched the candidate and stayed stable throughout the audit.", + failureClass: "none", + } + : fingerprintsAvailable + ? { + id: "workspace-fingerprint", + status: "fail", + summary: "Workspace fingerprint changed after completion was proposed or during its audit.", + failureClass: "quality", + } + : { + id: "workspace-fingerprint", + status: "blocked", + summary: fingerprintAfterResult.summary, + failureClass: "environment", + } const deterministicGates = Array.from( new Map( - [...preJudgeGates.filter((gate) => gate.id !== "workspace-fingerprint"), fingerprintGate, inspectionGate, successChecksGate(current, checkpoint, judgment)].filter( - (item): item is LoopGateResult => Boolean(item), - ).map((gate) => [gate.id, gate]), + [ + ...preJudgeGates.filter((gate) => gate.id !== "workspace-fingerprint"), + fingerprintGate, + inspectionGate, + successChecksGate(current, checkpoint, judgment), + ] + .filter((item): item is LoopGateResult => Boolean(item)) + .map((gate) => [gate.id, gate]), ).values(), ) - const gateResults = [...deterministicGates, evaluatorGate(judgment)].filter((item): item is LoopGateResult => Boolean(item)) + const gateResults = [...deterministicGates, evaluatorGate(judgment)].filter((item): item is LoopGateResult => + Boolean(item), + ) const receipt = auditReceipt({ workflow: current, candidate, @@ -1103,20 +1283,31 @@ export const layer = Layer.effect( after.ownerSessionID && after.ownerSessionID !== after.rootSessionID ) { - yield* prompt.prompt({ - sessionID: after.ownerSessionID, - agent: after.spec.agent, - model: promptModel(after), - variant: after.spec.model?.variant, - parts: [{ type: "text", text: parentCompletionPrompt(after, { ...checkpoint, summary: receipt.summary }, completed.id) }], - }).pipe(Effect.ignore) + yield* prompt + .prompt({ + sessionID: after.ownerSessionID, + agent: after.spec.agent, + model: promptModel(after), + variant: after.spec.model?.variant, + parts: [ + { + type: "text", + text: parentCompletionPrompt(after, { ...checkpoint, summary: receipt.summary }, completed.id), + }, + ], + }) + .pipe(Effect.ignore) } return { workflowID: id, runID: completed.id, - state: after.state === "blocked" || after.state === "needs_input" || after.state === "stopped" || after.state === "failed" - ? after.state - : "completed", + state: + after.state === "blocked" || + after.state === "needs_input" || + after.state === "stopped" || + after.state === "failed" + ? after.state + : "completed", summary: after.evaluatorReason ?? receipt.summary, } satisfies TickResult } @@ -1129,7 +1320,14 @@ export const layer = Layer.effect( return { workflowID: id, runID: failed.id, - state: failed.state === "blocked" ? "blocked" : failed.state === "needs_input" ? "needs_input" : failed.state === "stopped" ? "stopped" : "failed", + state: + failed.state === "blocked" + ? "blocked" + : failed.state === "needs_input" + ? "needs_input" + : failed.state === "stopped" + ? "stopped" + : "failed", summary: failed.evaluatorReason ?? error, } satisfies TickResult } @@ -1150,7 +1348,14 @@ export const layer = Layer.effect( return { workflowID: id, runID: failed.id, - state: failed.state === "blocked" ? "blocked" : failed.state === "needs_input" ? "needs_input" : failed.state === "stopped" ? "stopped" : "failed", + state: + failed.state === "blocked" + ? "blocked" + : failed.state === "needs_input" + ? "needs_input" + : failed.state === "stopped" + ? "stopped" + : "failed", summary: failed.evaluatorReason ?? error, } satisfies TickResult } @@ -1178,12 +1383,21 @@ export const layer = Layer.effect( const workflowExit = yield* Effect.exit(workflowRunner.run(startedWorkflow.run.id)) if (workflowExit._tag === "Failure") { const error = errorMessage(Cause.squash(workflowExit.cause)) - yield* workflowService.stop({ runID: startedWorkflow.run.id, reason: error, actor: "loop-adapter" }).pipe(Effect.catchCause(() => Effect.void)) + yield* workflowService + .stop({ runID: startedWorkflow.run.id, reason: error, actor: "loop-adapter" }) + .pipe(Effect.catchCause(() => Effect.void)) const failed = yield* workflow.failRun({ id, runID: run.id, error, failureClass: "environment", now }) return { workflowID: id, runID: failed.id, - state: failed.state === "blocked" ? "blocked" : failed.state === "needs_input" ? "needs_input" : failed.state === "stopped" ? "stopped" : "failed", + state: + failed.state === "blocked" + ? "blocked" + : failed.state === "needs_input" + ? "needs_input" + : failed.state === "stopped" + ? "stopped" + : "failed", summary: failed.evaluatorReason ?? error, } satisfies TickResult } @@ -1194,17 +1408,25 @@ export const layer = Layer.effect( return { workflowID: id, runID: failed.id, - state: failed.state === "blocked" ? "blocked" : failed.state === "needs_input" ? "needs_input" : failed.state === "stopped" ? "stopped" : "failed", + state: + failed.state === "blocked" + ? "blocked" + : failed.state === "needs_input" + ? "needs_input" + : failed.state === "stopped" + ? "stopped" + : "failed", summary: failed.evaluatorReason ?? error, } satisfies TickResult } - const goalStatus = finishedWorkflow.run.state === "completed" - ? "complete" as const - : finishedWorkflow.run.state === "stopped" - ? "stop" as const - : finishedWorkflow.run.state === "needs_input" - ? "needs_input" as const - : "blocked" as const + const goalStatus = + finishedWorkflow.run.state === "completed" + ? ("complete" as const) + : finishedWorkflow.run.state === "stopped" + ? ("stop" as const) + : finishedWorkflow.run.state === "needs_input" + ? ("needs_input" as const) + : ("blocked" as const) const usage = finishedWorkflow.usage ? { cost: finishedWorkflow.usage.cost, @@ -1217,12 +1439,18 @@ export const layer = Layer.effect( const completed = yield* workflow.completeRun({ id, runID: run.id, - reason: finishedWorkflow.run.state === "completed" ? "Referenced workflow completed." : `Referenced workflow is ${finishedWorkflow.run.state}.`, + reason: + finishedWorkflow.run.state === "completed" + ? "Referenced workflow completed." + : `Referenced workflow is ${finishedWorkflow.run.state}.`, now, goalStatus, checkpoint: { status: goalStatus, - summary: finishedWorkflow.run.state === "completed" ? "Referenced workflow completed." : `Referenced workflow is ${finishedWorkflow.run.state}.`, + summary: + finishedWorkflow.run.state === "completed" + ? "Referenced workflow completed." + : `Referenced workflow is ${finishedWorkflow.run.state}.`, evidence: finishedWorkflow.artifacts.slice(0, 8).map((artifact) => artifact.summary), }, usage, @@ -1231,11 +1459,18 @@ export const layer = Layer.effect( return { workflowID: id, runID: completed.id, - state: after.state === "blocked" || after.state === "needs_input" || after.state === "stopped" || after.state === "failed" ? after.state : "completed", + state: + after.state === "blocked" || + after.state === "needs_input" || + after.state === "stopped" || + after.state === "failed" + ? after.state + : "completed", summary: after.evaluatorReason ?? completed.evaluatorReason ?? "Referenced workflow run completed.", } satisfies TickResult } - const reportOnly = workflowIsReportOnly(current) || (input.reportOnly === true && !workflowExplicitlyAllowsEdits(current)) + const reportOnly = + workflowIsReportOnly(current) || (input.reportOnly === true && !workflowExplicitlyAllowsEdits(current)) const runStarted = Date.now() const result = yield* prompt .prompt({ @@ -1244,7 +1479,12 @@ export const layer = Layer.effect( model: promptModel(current), variant: current.spec.model?.variant, tools: loopIterationTools(reportOnly), - parts: [{ type: "text", text: reportOnly ? reportOnlyPrompt(current, input.reason) : iterationPrompt(current, input.reason) }], + parts: [ + { + type: "text", + text: reportOnly ? reportOnlyPrompt(current, input.reason) : iterationPrompt(current, input.reason), + }, + ], }) .pipe(Effect.exit) if (result._tag === "Failure") { @@ -1261,28 +1501,40 @@ export const layer = Layer.effect( } satisfies TickResult } if (result.value.info.role !== "assistant" || !result.value.info.finish) { - const reason = incompleteWorkerReason(result.value) - const failed = yield* workflow.failRun({ id, runID: run.id, error: reason, failureClass: "transient", now }) + const incomplete = incompleteWorkerFailure(result.value) + const failed = yield* workflow.failRun({ + id, + runID: run.id, + error: incomplete.reason, + failureClass: incomplete.failureClass, + now, + }) return { workflowID: id, runID: failed.id, - state: "failed" as const, + state: failed.state === "needs_input" ? ("needs_input" as const) : ("failed" as const), summary: failed.retry - ? `Loop run retry scheduled for ${new Date(failed.retry.nextWakeup ?? Date.now()).toISOString()}. ${reason}` - : (failed.evaluatorReason ?? reason), + ? `Loop run retry scheduled for ${new Date(failed.retry.nextWakeup ?? Date.now()).toISOString()}. ${incomplete.reason}` + : (failed.evaluatorReason ?? incomplete.reason), } satisfies TickResult } const checkpoint = parseCheckpoint(assistantText(result.value)) if (!checkpoint.status) { - const reason = incompleteWorkerReason(result.value) - const failed = yield* workflow.failRun({ id, runID: run.id, error: reason, failureClass: "transient", now }) + const incomplete = incompleteWorkerFailure(result.value) + const failed = yield* workflow.failRun({ + id, + runID: run.id, + error: incomplete.reason, + failureClass: incomplete.failureClass, + now, + }) return { workflowID: id, runID: failed.id, state: "failed" as const, summary: failed.retry - ? `Loop run retry scheduled for ${new Date(failed.retry.nextWakeup ?? Date.now()).toISOString()}. ${reason}` - : (failed.evaluatorReason ?? reason), + ? `Loop run retry scheduled for ${new Date(failed.retry.nextWakeup ?? Date.now()).toISOString()}. ${incomplete.reason}` + : (failed.evaluatorReason ?? incomplete.reason), } satisfies TickResult } const validationGates = yield* executeValidationChecks(workflow, current, run, checkpoint, instance.directory) @@ -1332,23 +1584,29 @@ export const layer = Layer.effect( const deterministicGates = [...preJudgeGates, successChecksGate(current, checkpoint, judgment)].filter( (item): item is LoopGateResult => Boolean(item), ) - const candidateFingerprintResult = current.spec.evaluation?.confirmation === "next-run" && checkpoint.status === "complete" - ? yield* Effect.promise(() => fingerprintWorkspace(run.workspaceLease?.state === "active" ? run.workspaceLease.path : instance.directory)) - : undefined + const candidateFingerprintResult = + current.spec.evaluation?.confirmation === "next-run" && checkpoint.status === "complete" + ? yield* Effect.promise(() => + fingerprintWorkspace( + run.workspaceLease?.state === "active" ? run.workspaceLease.path : instance.directory, + ), + ) + : undefined const gateResults = [ ...deterministicGates, evaluatorGate(judgment), candidateFingerprintResult ? workspaceFingerprintGate(candidateFingerprintResult) : undefined, ].filter((item): item is LoopGateResult => Boolean(item)) - const candidate = candidateFingerprintResult?.status === "ok" - ? completionCandidate({ - workflow: current, - run, - checkpoint, - fingerprint: candidateFingerprintResult.value, - now, - }) - : undefined + const candidate = + candidateFingerprintResult?.status === "ok" + ? completionCandidate({ + workflow: current, + run, + checkpoint, + fingerprint: candidateFingerprintResult.value, + now, + }) + : undefined const completed = yield* workflow.completeRun({ id, runID: run.id, @@ -1359,16 +1617,20 @@ export const layer = Layer.effect( judgment, rubricResult: evaluateRubric(current, checkpoint, judgment, gateResults), gateResults, - usage: mergeUsage([ - usageFromMessage(result.value, current, runStarted), - evaluatorUsage, - ]), + usage: mergeUsage([usageFromMessage(result.value, current, runStarted), evaluatorUsage]), ...(candidate ? { completion: { candidate } } : {}), }) const after = yield* workflow.get(id) const summary = - after.state === "blocked" || after.state === "needs_input" || after.state === "stopped" || after.state === "failed" - ? (completed.evaluatorReason ?? after.evaluatorReason ?? judgment?.summary ?? checkpoint.summary ?? "Loop run completed.") + after.state === "blocked" || + after.state === "needs_input" || + after.state === "stopped" || + after.state === "failed" + ? (completed.evaluatorReason ?? + after.evaluatorReason ?? + judgment?.summary ?? + checkpoint.summary ?? + "Loop run completed.") : (judgment?.summary ?? checkpoint.summary ?? completed.evaluatorReason ?? "Loop run completed.") if ( after.state === "completed" && @@ -1382,7 +1644,16 @@ export const layer = Layer.effect( agent: after.spec.agent, model: promptModel(after), variant: after.spec.model?.variant, - parts: [{ type: "text", text: parentCompletionPrompt(after, { ...checkpoint, summary: judgment?.summary ?? checkpoint.summary }, completed.id) }], + parts: [ + { + type: "text", + text: parentCompletionPrompt( + after, + { ...checkpoint, summary: judgment?.summary ?? checkpoint.summary }, + completed.id, + ), + }, + ], }) .pipe(Effect.ignore) } @@ -1410,20 +1681,24 @@ export const layer = Layer.effect( Effect.catchCause((cause) => { const error = errorMessage(Cause.squash(cause)) return workflow.recordSchedulerFailure({ id: item.id, error, now }).pipe( - Effect.map((failed) => ({ - workflowID: item.id, - runID: failed.id, - state: failed.state === "blocked" - ? "blocked" - : failed.state === "needs_input" - ? "needs_input" - : failed.state === "stopped" - ? "stopped" - : failed.state === "completed" - ? "completed" - : "failed", - summary: failed.evaluatorReason ?? `Loop scheduler failed: ${error}`, - }) satisfies TickResult), + Effect.map( + (failed) => + ({ + workflowID: item.id, + runID: failed.id, + state: + failed.state === "blocked" + ? "blocked" + : failed.state === "needs_input" + ? "needs_input" + : failed.state === "stopped" + ? "stopped" + : failed.state === "completed" + ? "completed" + : "failed", + summary: failed.evaluatorReason ?? `Loop scheduler failed: ${error}`, + }) satisfies TickResult, + ), Effect.catchCause(() => Effect.succeed({ workflowID: item.id, diff --git a/src/mendcode/packages/opencode/src/session/prompt.ts b/src/mendcode/packages/opencode/src/session/prompt.ts index 44b9c115..c14f74a0 100644 --- a/src/mendcode/packages/opencode/src/session/prompt.ts +++ b/src/mendcode/packages/opencode/src/session/prompt.ts @@ -56,6 +56,7 @@ import { TaskTool, type TaskPromptOps } from "@/tool/task" import { BackgroundTask } from "./background-task" import { WorkflowService } from "./workflow-service" import { SessionRunState } from "./run-state" +import { AgentCommand } from "./agent-command" import { EffectBridge } from "@/effect/bridge" import { EventV2 } from "@/v2/event" import { SessionEvent } from "@/v2/session-event" @@ -165,15 +166,14 @@ export function findRecoverableQueuedPrompt( ), ) const explicitlyQueued = ordered.find( - (message) => - message.info.role === "user" && message.info.queued === true && !answeredUserIDs.has(message.info.id), + (message) => message.info.role === "user" && message.info.queued === true && !answeredUserIDs.has(message.info.id), ) if (explicitlyQueued) return explicitlyQueued const latestAssistantIndex = latestAssistant ? ordered.indexOf(latestAssistant) : -1 - return ordered.slice(latestAssistantIndex + 1).find( - (message) => message.info.role === "user" && options?.includeLegacy === true, - ) + return ordered + .slice(latestAssistantIndex + 1) + .find((message) => message.info.role === "user" && options?.includeLegacy === true) } function retainedSubtaskText(input: string) { @@ -477,6 +477,7 @@ export interface Interface { readonly loop: (input: LoopInput) => Effect.Effect readonly shell: (input: ShellInput) => Effect.Effect readonly command: (input: CommandInput) => Effect.Effect + readonly wakePeerDelivery: (targetSessionID: SessionID) => Effect.Effect readonly resolvePromptParts: (template: string) => Effect.Effect } @@ -507,6 +508,7 @@ export const layer = Layer.effect( const scope = yield* Scope.Scope const instruction = yield* Instruction.Service const state = yield* SessionRunState.Service + const agentCommands = yield* AgentCommand.Service const revert = yield* SessionRevert.Service const summary = yield* SessionSummary.Service const sys = yield* SystemPrompt.Service @@ -520,12 +522,12 @@ export const layer = Layer.effect( const scheduledAsyncPrompts = new Set() const recoveredWorkflowSessions = new Map() let ownerWakeState: InstanceState.InstanceState - const acknowledgeOwnerWakeNotifications = Effect.fn("SessionPrompt.acknowledgeOwnerWakeNotifications")( - function* (eventIDs: readonly string[]) { - yield* backgroundTasks.acknowledgeNotifications(eventIDs) - yield* workflows.acknowledgeNotifications(eventIDs) - }, - ) + const acknowledgeOwnerWakeNotifications = Effect.fn("SessionPrompt.acknowledgeOwnerWakeNotifications")(function* ( + eventIDs: readonly string[], + ) { + yield* backgroundTasks.acknowledgeNotifications(eventIDs) + yield* workflows.acknowledgeNotifications(eventIDs) + }) const isManualAbort = (sessionID: SessionID) => promptAbortReasons.get(sessionID) === "user" const runner = Effect.fn("SessionPrompt.runner")(function* () { return yield* EffectBridge.make() @@ -632,6 +634,10 @@ export const layer = Layer.effect( const cancelTurn = Effect.fn("SessionPrompt.cancelTurn")(function* (input: CancelTurnInput) { yield* elog.info("cancel-turn", { ...input, important: true }) + // The user explicitly asked this session to stop. Suppress pending owner + // wakes even when the targeted turn crossed into terminal state before + // the cancel request reached the runner. + yield* cancelOwnerWakeSession(input.sessionID) const active = promptAbortControllers.get(input.sessionID) let interruptedAssistantID: MessageID | undefined let markedUserAbort = false @@ -639,7 +645,6 @@ export const layer = Layer.effect( .cancelTurn(input.sessionID, input.targetMessageID, { ignoreInterruptible: true, before: Effect.gen(function* () { - yield* cancelOwnerWakeSession(input.sessionID) promptAbortReasons.set(input.sessionID, "user") markedUserAbort = true const orphanedAssistant = yield* sessions.findMessage( @@ -965,7 +970,7 @@ export const layer = Layer.effect( providerID: input.providerID, modelID: input.modelID, messages: [{ role: "user", content }], - }) + }) }) const cancelQueued = Effect.fn("SessionPrompt.cancelQueued")(function* (input: { @@ -1237,16 +1242,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the { args }, ) const result = yield* item.execute(args, ctx).pipe( - Effect.ensuring( - Effect.promise(() => - releaseMflowLocks({ - root: instance.directory, - files: mflowLocks.locked, - owner: mflowLocks.owner, - }), - ), + Effect.ensuring( + Effect.promise(() => + releaseMflowLocks({ + root: instance.directory, + files: mflowLocks.locked, + owner: mflowLocks.owner, + }), ), - ) + ), + ) const output = { ...result, attachments: result.attachments?.map((attachment) => ({ @@ -1365,7 +1370,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the tools[key] = item } - return tools }) @@ -1455,14 +1459,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the abortReason: () => (isManualAbort(sessionID) ? "user" : undefined), }, messages: msgs, - metadata: (val: { title?: string; metadata?: Record }) => - Effect.gen(function* () { - part = yield* sessions.updatePart({ - ...part, - type: "tool", - state: { ...part.state, ...val }, - } satisfies MessageV2.ToolPart) - }), + metadata: (val: { title?: string; metadata?: Record }) => + Effect.gen(function* () { + part = yield* sessions.updatePart({ + ...part, + type: "tool", + state: { ...part.state, ...val }, + } satisfies MessageV2.ToolPart) + }), ask: (req: any) => permission .ask({ @@ -2298,11 +2302,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the return wake }) - const prompt: ( - input: PromptInput, - options?: { queued?: boolean }, - ) => Effect.Effect = Effect.fn("SessionPrompt.prompt")( - function* (input: PromptInput, options?: { queued?: boolean }) { + const prompt: (input: PromptInput, options?: { queued?: boolean }) => Effect.Effect = + Effect.fn("SessionPrompt.prompt")(function* (input: PromptInput, options?: { queued?: boolean }) { log.trace("prompt-start", { sessionID: input.sessionID, messageID: input.messageID, @@ -2337,10 +2338,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the throw new NamedError.Unknown({ message: `Message ${input.messageID} is not a user prompt.` }) } if (input.replaceExisting) { - const message = yield* createUserMessage(input, { - info: existing.value.info as MessageV2.User, - parts: existing.value.parts, - }, options) + const message = yield* createUserMessage( + input, + { + info: existing.value.info as MessageV2.User, + parts: existing.value.parts, + }, + options, + ) yield* sessions.touch(input.sessionID) if (ownerWakeAcknowledgementIDs.length > 0) yield* acknowledgeOwnerWakeNotifications(ownerWakeAcknowledgementIDs) @@ -2373,8 +2378,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (input.noReply === true) return message log.trace("prompt-queue-run", { sessionID: input.sessionID, messageID: message.info.id }) return yield* loop({ sessionID: input.sessionID, queue: true, targetMessageID: message.info.id }) - }, - ) + }) const lastAssistant = Effect.fnUntraced(function* (sessionID: SessionID) { const match = yield* sessions.findMessage(sessionID, (m) => m.info.role !== "user") @@ -2665,19 +2669,19 @@ NOTE: At any point in time through this workflow you should feel free to ask the ? latestAcceptedPlanReviewContext(yield* sessions.messages({ sessionID })) : undefined if (latestPlanReview) system.push(latestPlanReview) - const reviewContext = reviewContextForAssistant(ctx.worktree || ctx.directory) - if (reviewContext) { - system.push(`\n${reviewContext}\n`) - } - const interruptedToolPrompt = interruptedToolPromptText(msgs) - if (interruptedToolPrompt) system.push(interruptedToolPrompt) - const format = lastUser.format ?? { type: "text" as const } + const reviewContext = reviewContextForAssistant(ctx.worktree || ctx.directory) + if (reviewContext) { + system.push(`\n${reviewContext}\n`) + } + const interruptedToolPrompt = interruptedToolPromptText(msgs) + if (interruptedToolPrompt) system.push(interruptedToolPrompt) + const format = lastUser.format ?? { type: "text" as const } if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) const streamMessages = [ ...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : []), ] - const promptTokens = Token.estimatePayload({ + const promptTokens = Token.estimatePayload({ system: [ ...(agent.prompt ? [agent.prompt] : mendPrompt.baseProvider), mendPrompt.focus, @@ -2685,50 +2689,50 @@ NOTE: At any point in time through this workflow you should feel free to ask the mendPrompt.memory, ...system, ], - messages: streamMessages, - tools: Object.fromEntries( - Object.entries(tools).map(([name, item]) => { - const toolInfo = item as Record - return [ - name, - { - description: toolInfo.description, - inputSchema: toolInfo.inputSchema ?? toolInfo.parameters, - }, - ] - }), - ), - }) - const previousContextTokens = lastFinished - ? lastFinished.tokens.input + - lastFinished.tokens.output + - lastFinished.tokens.reasoning + - lastFinished.tokens.cache.read + - lastFinished.tokens.cache.write - : undefined - const previousContextAtThreshold = - previousContextTokens === undefined - ? undefined - : yield* compaction.isPromptOverflow({ - tokens: previousContextTokens, - model, - respectAuto: false, - mode: "threshold", - }) - const promptOverflow = yield* compaction.isPromptOverflow({ - tokens: promptTokens, - model, - respectAuto: false, - mode: "hard", - }) - const hardLimit = modelContextLimit({ model }) - if (shouldPreflightPromptOverflow({ promptOverflow, promptTokens, hardLimit, previousContextAtThreshold })) { + messages: streamMessages, + tools: Object.fromEntries( + Object.entries(tools).map(([name, item]) => { + const toolInfo = item as Record + return [ + name, + { + description: toolInfo.description, + inputSchema: toolInfo.inputSchema ?? toolInfo.parameters, + }, + ] + }), + ), + }) + const previousContextTokens = lastFinished + ? lastFinished.tokens.input + + lastFinished.tokens.output + + lastFinished.tokens.reasoning + + lastFinished.tokens.cache.read + + lastFinished.tokens.cache.write + : undefined + const previousContextAtThreshold = + previousContextTokens === undefined + ? undefined + : yield* compaction.isPromptOverflow({ + tokens: previousContextTokens, + model, + respectAuto: false, + mode: "threshold", + }) + const promptOverflow = yield* compaction.isPromptOverflow({ + tokens: promptTokens, + model, + respectAuto: false, + mode: "hard", + }) + const hardLimit = modelContextLimit({ model }) + if (shouldPreflightPromptOverflow({ promptOverflow, promptTokens, hardLimit, previousContextAtThreshold })) { const duplicateAutoCompaction = shouldSkipAutoCompaction(msgs) const rescueCompactions = autoRescueCompactionCount(msgs) const rescueAlreadyAttempted = rescueCompactions > 0 const canResumeAfterRescue = shouldResumeAfterAutoRescueCompaction(msgs) - yield* beginCompaction() - yield* slog.warn("pre-provider compaction", { + yield* beginCompaction() + yield* slog.warn("pre-provider compaction", { promptTokens, reason: duplicateAutoCompaction ? "synthetic resume still exceeded effective provider/model threshold before request dispatch" @@ -2804,13 +2808,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the const hasUsefulParts = handleParts.some( (part) => part.type === "text" || part.type === "tool" || part.type === "patch" || part.type === "file", ) - if (!handle.message.finish && !hasUsefulParts) { + if (!handle.message.finish && !hasUsefulParts) { yield* sessions .removeMessage({ sessionID, messageID: handle.message.id }) .pipe(Effect.catch(() => Effect.void)) - } - yield* beginCompaction() - yield* compaction.create({ + } + yield* beginCompaction() + yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, @@ -2824,21 +2828,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the : "The prompt still exceeded the provider/model context after the preserved tail was discarded. Produce the smallest final rescue summary without auto-resuming; preserve active work, TODOs, latest tool state, changed files, verification evidence, and transcript reference.", }) return "continue" as const - } - const resumeAfterCompaction = shouldResumeAfterActiveCompaction( - handle.message.finish, - hasRunnableToolCalls(MessageV2.parts(handle.message.id)), - ) - yield* beginCompaction() - yield* compaction.create({ + } + const resumeAfterCompaction = shouldResumeAfterActiveCompaction( + handle.message.finish, + hasRunnableToolCalls(MessageV2.parts(handle.message.id)), + ) + yield* beginCompaction() + yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true, overflow: true, resume: resumeAfterCompaction, - }) - return "continue" as const + }) + return "continue" as const } if (finished && !handle.message.error && !hasRunnableToolCalls(MessageV2.parts(handle.message.id))) { return "break" as const @@ -2877,9 +2881,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the } const result = - backgroundGeneration === recovery.backgroundGeneration - ? recovery - : { ...recovery, backgroundGeneration } + backgroundGeneration === recovery.backgroundGeneration ? recovery : { ...recovery, backgroundGeneration } recoveredWorkflowSessions.set(sessionID, result) return result }) @@ -2892,21 +2894,20 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (!recovery) return if (recovery.runnerManaged) { - yield* workflows - .wake(recovery.runID) - .pipe( - Effect.catchCause(() => Effect.void), - Effect.ensuring(Effect.sync(() => recoveredWorkflowSessions.delete(sessionID))), - ) + yield* workflows.wake(recovery.runID).pipe( + Effect.catchCause(() => Effect.void), + Effect.ensuring(Effect.sync(() => recoveredWorkflowSessions.delete(sessionID))), + ) return } const successful = message.info.role === "assistant" && Boolean(message.info.finish) && message.info.error === undefined - const summary = message.parts - .flatMap((part) => (part.type === "text" && !part.ignored ? [part.text] : [])) - .join("\n") - .trim() || undefined + const summary = + message.parts + .flatMap((part) => (part.type === "text" && !part.ignored ? [part.text] : [])) + .join("\n") + .trim() || undefined const state = successful ? ("completed" as const) : ("failed" as const) const error = successful ? undefined @@ -2948,9 +2949,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* resumeWorkflowTaskSession(input.sessionID) let interruptedAssistantID: MessageID | undefined const queueMode = (yield* config.get()).queue?.mode ?? "after-response" - const targetMessageID = input.targetMessageID ?? Option.getOrUndefined( - yield* sessions.findMessage(input.sessionID, (message) => message.info.role === "user"), - )?.info.id + const targetMessageID = + input.targetMessageID ?? + Option.getOrUndefined(yield* sessions.findMessage(input.sessionID, (message) => message.info.role === "user")) + ?.info.id log.trace("loop-ensure-running", { sessionID: input.sessionID, targetMessageID, @@ -3046,8 +3048,143 @@ NOTE: At any point in time through this workflow you should feel free to ask the Effect.forkIn(scope, { startImmediately: true }), ) return message + }) + + // Peer deliveries are durable inbox entries, not Bus messages. They are + // claimed per target only after the target is idle, then persisted through + // the normal prompt boundary so a restart can detect the delivery marker + // and avoid inserting the same prompt twice. + const peerDeliveryTargets = new Set() + const peerDeliveryPendingTargets = new Set() + const deliverPeerMessage = (info: AgentCommand.Info): Effect.Effect => + Effect.gen(function* () { + if (info.type !== "peer_message" || (info.state !== "accepted" && info.state !== "running")) return + if (peerDeliveryTargets.has(info.targetSessionID)) return + peerDeliveryTargets.add(info.targetSessionID) + let settled = false + try { + const statusInfo = yield* status.get(info.targetSessionID) + if (statusInfo.type !== "idle") return + if (info.policy.decision !== "same_workspace") { + yield* agentCommands.update({ + id: info.id, + targetSessionID: info.targetSessionID, + state: "rejected", + error: "Peer delivery requires both sessions to belong to the same workspace.", + }) + settled = true + return + } + + if (info.state === "accepted") { + try { + yield* agentCommands.update({ + id: info.id, + targetSessionID: info.targetSessionID, + state: "running", + }) + } catch (error) { + if (error instanceof AgentCommand.InvalidStateTransitionError) return + throw error + } + } + + const existing = yield* sessions.messages({ sessionID: info.targetSessionID, view: "full" }) + const marker = existing.find((message) => + message.parts.some( + (part) => + part.type === "text" && + (part.metadata as Record | undefined)?.kind === "peer_message" && + (part.metadata as Record | undefined)?.deliveryID === info.id, + ), + ) + if (marker) { + yield* agentCommands.update({ + id: info.id, + targetSessionID: info.targetSessionID, + state: "completed", + result: `Peer message already persisted in prompt ${marker.info.id}.`, + }) + settled = true + return + } + + const message = yield* promptAsync({ + sessionID: info.targetSessionID, + parts: [ + { + type: "text", + text: info.payload.text, + metadata: { + kind: "peer_message", + deliveryID: info.id, + sourceSessionID: info.sourceSessionID, + sourceTitle: info.payload.sourceTitle ?? info.sourceSessionID, + receivedAt: Date.now(), + }, + }, + ], + }) + yield* agentCommands.update({ + id: info.id, + targetSessionID: info.targetSessionID, + state: "completed", + result: `Peer message persisted in prompt ${message.info.id}.`, + }) + settled = true + } catch (error) { + yield* agentCommands.update({ + id: info.id, + targetSessionID: info.targetSessionID, + state: "failed", + error: error instanceof Error ? error.message : String(error), + }) + settled = true + } finally { + peerDeliveryTargets.delete(info.targetSessionID) + if (settled) yield* scheduleNextPeerDelivery(info.targetSessionID) + } + }).pipe(Effect.orDie) + + const schedulePeerDelivery = (info: AgentCommand.Info): Effect.Effect => + Effect.gen(function* () { + if (info.type !== "peer_message" || (info.state !== "accepted" && info.state !== "running")) return + yield* deliverPeerMessage(info).pipe(Effect.forkIn(scope, { startImmediately: true })) }) + const scheduleNextPeerDelivery = (targetSessionID: SessionID): Effect.Effect => + Effect.gen(function* () { + if (!peerDeliveryPendingTargets.has(targetSessionID)) return + const next = (yield* agentCommands.list({ targetSessionID })) + .filter( + (item): item is Extract => + item.type === "peer_message" && (item.state === "accepted" || item.state === "running"), + ) + .toSorted((a, b) => a.time.created - b.time.created || a.id.localeCompare(b.id))[0] + if (next) { + yield* schedulePeerDelivery(next) + return + } + peerDeliveryPendingTargets.delete(targetSessionID) + }) + + const enqueuePeerDelivery = (info: AgentCommand.Info): Effect.Effect => + Effect.gen(function* () { + if (info.type !== "peer_message" || info.state !== "accepted") return + peerDeliveryPendingTargets.add(info.targetSessionID) + yield* scheduleNextPeerDelivery(info.targetSessionID) + }) + + const wakePeerDelivery = Effect.fn("SessionPrompt.wakePeerDelivery")(function* (targetSessionID: SessionID) { + const pending = (yield* agentCommands.list({ targetSessionID })).filter( + (info): info is Extract => + info.type === "peer_message" && (info.state === "accepted" || info.state === "running"), + ) + if (pending.length === 0) return + peerDeliveryPendingTargets.add(targetSessionID) + yield* scheduleNextPeerDelivery(targetSessionID) + }) + const recoverQueuedPrompt = Effect.fn("SessionPrompt.recoverQueuedPrompt")(function* ( sessionID: SessionID, options?: { includeLegacy?: boolean }, @@ -3055,42 +3192,43 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (queuedPromptRecovery.has(sessionID)) return queuedPromptRecovery.add(sessionID) - const messages = yield* sessions.messages({ sessionID, view: "full" }).pipe( - Effect.catchCause((cause) => - Effect.logError("queued prompt recovery inspection failed", { sessionID, cause }).pipe( - Effect.as([] as MessageV2.WithParts[]), + const messages = yield* sessions + .messages({ sessionID, view: "full" }) + .pipe( + Effect.catchCause((cause) => + Effect.logError("queued prompt recovery inspection failed", { sessionID, cause }).pipe( + Effect.as([] as MessageV2.WithParts[]), + ), ), - ), - ) + ) const incomplete = messages.findLast( (message) => message.info.role === "assistant" && message.info.time.completed === undefined, ) const inspectedSession = incomplete ? yield* sessions.get(sessionID).pipe(Effect.catchCause(() => Effect.succeed(undefined))) : undefined - const orphaned = incomplete && !inspectedSession?.parentID && messages.some( - (message) => - message.info.role === "user" && - message.info.queued === true && - message.info.time.created > incomplete.info.time.created, - ) - ? incomplete - : undefined + const orphaned = + incomplete && + !inspectedSession?.parentID && + messages.some( + (message) => + message.info.role === "user" && + message.info.queued === true && + message.info.time.created > incomplete.info.time.created, + ) + ? incomplete + : undefined if (orphaned && (yield* state.isBusy(sessionID))) { queuedPromptRecovery.delete(sessionID) return } if (orphaned) { - yield* finishOrphanedAssistant( - sessionID, - orphaned.info.id, - { reason: "Runtime interrupted before the assistant response completed." }, - ) + yield* finishOrphanedAssistant(sessionID, orphaned.info.id, { + reason: "Runtime interrupted before the assistant response completed.", + }) } const recoveredMessages = orphaned - ? yield* sessions.messages({ sessionID, view: "full" }).pipe( - Effect.catchCause(() => Effect.succeed(messages)), - ) + ? yield* sessions.messages({ sessionID, view: "full" }).pipe(Effect.catchCause(() => Effect.succeed(messages))) : messages const queued = findRecoverableQueuedPrompt(recoveredMessages, options) if (!queued) { @@ -3253,79 +3391,77 @@ NOTE: At any point in time through this workflow you should feel free to ask the const drainOwnerWake: (sessionID: SessionID, current?: OwnerWakeState) => Effect.Effect = Effect.fn( "SessionPrompt.drainOwnerWake", - )( - function* (sessionID: SessionID, current?: OwnerWakeState) { - const wakeState = current ?? (yield* InstanceState.get(ownerWakeState)) - const cfg = yield* config.get() - if (cfg.subagent_owner_wake === false) { - wakeState.pending.delete(sessionID) - return - } - if (!wakeState.sessions.has(sessionID) || wakeState.running.has(sessionID)) return - if (yield* state.isBusy(sessionID)) return - - const pending = wakeState.pending.get(sessionID) - if (!pending || pending.size === 0) return - const session = yield* sessions.get(sessionID).pipe(Effect.catchCause(() => Effect.succeed(undefined))) - if (!session) return - - const events = Array.from(pending.values()).slice(0, OWNER_WAKE_MAX_EVENTS) - events.forEach((event) => pending.delete(event.eventID)) - if (pending.size === 0) wakeState.pending.delete(sessionID) - - const wakeID = Bus.createID() - const model = session.model - ? { - providerID: session.model.providerID, - modelID: session.model.id, - } - : undefined - const wake = prompt({ - sessionID, - agent: session.agent, - model, - variant: session.model?.variant, - parts: [ - { - type: "text", - text: ownerWakePromptText(events), - synthetic: true, - metadata: { - kind: OWNER_WAKE_PART_KIND, - wakeID, - eventIDs: events.map((event) => event.eventID), - }, - }, - ], - }).pipe( - Effect.ensuring(Effect.sync(() => wakeState.running.delete(sessionID))), - Effect.catchCause((cause) => - Effect.sync(() => { - const retry = wakeState.pending.get(sessionID) ?? new Map() - events.forEach((event) => retry.set(event.eventID, event)) - wakeState.pending.set(sessionID, retry) - log.error("owner wake failed", { - sessionID, - error: Cause.squash(cause), - }) - }), - ), - ) - - wakeState.running.add(sessionID) - yield* wake.pipe(Effect.forkIn(scope)) - yield* bus.publish( - BackgroundTask.Event.OwnerWake, + )(function* (sessionID: SessionID, current?: OwnerWakeState) { + const wakeState = current ?? (yield* InstanceState.get(ownerWakeState)) + const cfg = yield* config.get() + if (cfg.subagent_owner_wake === false) { + wakeState.pending.delete(sessionID) + return + } + if (!wakeState.sessions.has(sessionID) || wakeState.running.has(sessionID)) return + if (yield* state.isBusy(sessionID)) return + + const pending = wakeState.pending.get(sessionID) + if (!pending || pending.size === 0) return + const session = yield* sessions.get(sessionID).pipe(Effect.catchCause(() => Effect.succeed(undefined))) + if (!session) return + + const events = Array.from(pending.values()).slice(0, OWNER_WAKE_MAX_EVENTS) + events.forEach((event) => pending.delete(event.eventID)) + if (pending.size === 0) wakeState.pending.delete(sessionID) + + const wakeID = Bus.createID() + const model = session.model + ? { + providerID: session.model.providerID, + modelID: session.model.id, + } + : undefined + const wake = prompt({ + sessionID, + agent: session.agent, + model, + variant: session.model?.variant, + parts: [ { - wakeID, - parentSessionID: sessionID, - taskIDs: events.map((event) => event.taskID), - taskTitles: events.map((event) => event.title.slice(0, 96)), + type: "text", + text: ownerWakePromptText(events), + synthetic: true, + metadata: { + kind: OWNER_WAKE_PART_KIND, + wakeID, + eventIDs: events.map((event) => event.eventID), + }, }, - { id: wakeID }, - ) - }, - ) + ], + }).pipe( + Effect.ensuring(Effect.sync(() => wakeState.running.delete(sessionID))), + Effect.catchCause((cause) => + Effect.sync(() => { + const retry = wakeState.pending.get(sessionID) ?? new Map() + events.forEach((event) => retry.set(event.eventID, event)) + wakeState.pending.set(sessionID, retry) + log.error("owner wake failed", { + sessionID, + error: Cause.squash(cause), + }) + }), + ), + ) + + wakeState.running.add(sessionID) + yield* wake.pipe(Effect.forkIn(scope)) + yield* bus.publish( + BackgroundTask.Event.OwnerWake, + { + wakeID, + parentSessionID: sessionID, + taskIDs: events.map((event) => event.taskID), + taskTitles: events.map((event) => event.title.slice(0, 96)), + }, + { id: wakeID }, + ) + }) const scheduleOwnerWake: (sessionID: SessionID, current?: OwnerWakeState) => Effect.Effect = Effect.fn( "SessionPrompt.scheduleOwnerWake", @@ -3397,15 +3533,37 @@ NOTE: At any point in time through this workflow you should feel free to ask the (sessionID) => scheduleOwnerWake(sessionID, value), { discard: true }, ) - yield* Effect.forEach( - yield* sessions.list({ limit: 100 }), - (session) => recoverQueuedPrompt(session.id), - { concurrency: 8, discard: true }, - ) + yield* Effect.forEach(yield* sessions.list({ limit: 100 }), (session) => recoverQueuedPrompt(session.id), { + concurrency: 8, + discard: true, + }) return value }), ) + yield* bus.subscribe(AgentCommand.Event.Updated).pipe( + Stream.filter((event) => event.properties.info.type === "peer_message"), + Stream.runForEach((event) => { + if (event.properties.info.state !== "accepted") return Effect.void + return enqueuePeerDelivery(event.properties.info) + }), + Effect.forkScoped({ startImmediately: true }), + ) + yield* bus.subscribe(SessionStatus.Event.Status).pipe( + Stream.filter((event) => event.properties.status.type === "idle"), + Stream.runForEach((event) => scheduleNextPeerDelivery(event.properties.sessionID)), + Effect.forkScoped({ startImmediately: true }), + ) + yield* Effect.gen(function* () { + yield* Effect.sleep("1 millis") + const targets = new Set( + (yield* agentCommands.list()) + .filter((info) => info.type === "peer_message" && (info.state === "accepted" || info.state === "running")) + .map((info) => info.targetSessionID), + ) + for (const targetSessionID of targets) peerDeliveryPendingTargets.add(targetSessionID) + yield* Effect.forEach(targets, (targetSessionID) => scheduleNextPeerDelivery(targetSessionID), { discard: true }) + }).pipe(Effect.forkScoped({ startImmediately: false })) return Service.of({ cancel, cancelTurn, @@ -3417,6 +3575,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the shell, command, resolvePromptParts, + wakePeerDelivery, }) }), ) @@ -3438,7 +3597,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Plugin.defaultLayer), - Layer.provide(Session.defaultLayer), + Layer.provide(Layer.mergeAll(Session.defaultLayer, AgentCommand.defaultLayer)), Layer.provide(SessionRevert.defaultLayer), Layer.provide(SessionSummary.defaultLayer), Layer.provide(Layer.mergeAll(BackgroundTask.defaultLayer, WorkflowService.defaultLayer)), diff --git a/src/mendcode/packages/opencode/src/session/session.sql.ts b/src/mendcode/packages/opencode/src/session/session.sql.ts index d79b4bda..c8e88ce1 100644 --- a/src/mendcode/packages/opencode/src/session/session.sql.ts +++ b/src/mendcode/packages/opencode/src/session/session.sql.ts @@ -228,13 +228,14 @@ export const AgentCommandTable = sqliteTable( time_created: integer().notNull(), time_updated: integer().notNull(), data: text({ mode: "json" }).notNull().$type<{ - type: "request_summary" | "rename" | "tag" | "pause_after_turn" | "stop" | "send_message" + type: "request_summary" | "rename" | "tag" | "pause_after_turn" | "stop" | "send_message" | "peer_message" payload: | { instructions?: string } | { title: string } | { tags: readonly string[] } | { reason?: string } | { text: string } + | { text: string; sourceTitle?: string } permissions: readonly string[] policy?: { decision: "safe_auto" | "same_workspace" | "approval_required" | "denied" diff --git a/src/mendcode/packages/opencode/src/tool/peers.ts b/src/mendcode/packages/opencode/src/tool/peers.ts new file mode 100644 index 00000000..b6324abf --- /dev/null +++ b/src/mendcode/packages/opencode/src/tool/peers.ts @@ -0,0 +1,105 @@ +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import DESCRIPTION from "./peers.txt" +import { Session } from "@/session/session" +import { SessionStatus } from "@/session/status" +import { NonNegativeInt } from "@/util/schema" + +const MAX_PEERS = 32 +const MAX_ACTIVITY_LENGTH = 120 + +export const Parameters = Schema.Struct({ + limit: Schema.optional(NonNegativeInt).annotate({ + description: "Maximum number of peer descriptors to return. The server caps this at 32.", + }), +}) + +export type PeerDescriptor = { + sessionID: string + title: string + state: string + activity?: string + workspaceID?: string + updatedAt: number +} + +function statusDescriptor(status: SessionStatus.Info) { + if (status.type === "idle") return { state: "idle" as const } + if (status.type === "retry") { + return { + state: "retry" as const, + activity: status.message.slice(0, MAX_ACTIVITY_LENGTH), + } + } + const activity = status.message?.trim() || status.kind + return { + state: "busy" as const, + ...(activity ? { activity: activity.slice(0, MAX_ACTIVITY_LENGTH) } : {}), + } +} + +export function peerDescriptors(input: { + sessions: readonly Session.Info[] + statuses: ReadonlyMap + currentSessionID: string + limit?: number +}): PeerDescriptor[] { + const limit = Math.min(MAX_PEERS, Math.max(1, Math.floor(input.limit ?? MAX_PEERS))) + return input.sessions + .filter((session) => session.id !== input.currentSessionID) + .toSorted((a, b) => b.time.updated - a.time.updated || a.id.localeCompare(b.id)) + .slice(0, limit) + .map((session) => { + const status = statusDescriptor(input.statuses.get(session.id) ?? { type: "idle" }) + return { + sessionID: session.id, + title: session.title.slice(0, 160), + ...status, + ...(session.workspaceID ? { workspaceID: session.workspaceID } : {}), + updatedAt: session.time.updated, + } + }) +} + +export const PeersTool = Tool.define( + "peers", + Effect.gen(function* () { + const sessions = yield* Session.Service + const statuses = yield* SessionStatus.Service + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const [items, statusMap] = yield* Effect.all([sessions.list({ limit: MAX_PEERS + 1 }), statuses.list()]) + const peers = peerDescriptors({ + sessions: items, + statuses: statusMap, + currentSessionID: ctx.sessionID, + limit: params.limit, + }) + const output = peers.length + ? peers + .map((peer) => + [ + `sessionID=${peer.sessionID}`, + `title=${JSON.stringify(peer.title)}`, + `state=${peer.state}`, + peer.activity ? `activity=${JSON.stringify(peer.activity)}` : undefined, + peer.workspaceID ? `workspaceID=${peer.workspaceID}` : undefined, + `updatedAt=${peer.updatedAt}`, + ] + .filter(Boolean) + .join(" "), + ) + .join("\n") + : "No other peer sessions are available in this project." + return { + title: `Peers (${peers.length})`, + output, + metadata: { count: peers.length }, + } + }), + } + }), +) diff --git a/src/mendcode/packages/opencode/src/tool/peers.txt b/src/mendcode/packages/opencode/src/tool/peers.txt new file mode 100644 index 00000000..7690a86c --- /dev/null +++ b/src/mendcode/packages/opencode/src/tool/peers.txt @@ -0,0 +1,3 @@ +List the other active MendCode sessions in the current project as bounded peer descriptors. + +The result contains only explicit session IDs, titles, lifecycle state, short activity, workspace identity, and update time. It never reads or exposes transcript text, files, reasoning, permissions, secrets, or provider context. Use the exact session ID from this list with the tell tool. diff --git a/src/mendcode/packages/opencode/src/tool/registry.ts b/src/mendcode/packages/opencode/src/tool/registry.ts index 6511a1d6..8f7ab772 100644 --- a/src/mendcode/packages/opencode/src/tool/registry.ts +++ b/src/mendcode/packages/opencode/src/tool/registry.ts @@ -62,6 +62,9 @@ import { LoopRunner } from "@/session/loop-runner" import { WorkflowService } from "@/session/workflow-service" import { BackgroundTask } from "@/session/background-task" import { Auth } from "@/auth" +import { AgentCommand } from "@/session/agent-command" +import { TellTool } from "./tell" +import { PeersTool } from "./peers" const log = Log.create({ service: "tool.registry" }) @@ -111,6 +114,7 @@ export const layer: Layer.Layer< | Ripgrep.Service | Format.Service | Truncate.Service + | AgentCommand.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -143,6 +147,8 @@ export const layer: Layer.Layer< const looptool = yield* LoopTool const workflowtool = yield* WorkflowTool const reviewtool = yield* ReviewTool + const telltool = yield* TellTool + const peerstool = yield* PeersTool const memorytool = yield* MemoryTool const memorygraphtool = yield* MemoryGraphTool @@ -244,6 +250,8 @@ export const layer: Layer.Layer< loop: Tool.init(looptool), workflow: Tool.init(workflowtool), review: Tool.init(reviewtool), + peers: Tool.init(peerstool), + tell: Tool.init(telltool), memory: Tool.init(memorytool), memoryGraph: Tool.init(memorygraphtool), imageGen: Tool.init(imagegentool), @@ -274,6 +282,8 @@ export const layer: Layer.Layer< tool.loop, tool.workflow, tool.review, + tool.peers, + tool.tell, tool.memory, tool.memoryGraph, tool.imageGen, @@ -437,7 +447,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide([LoopRunner.defaultLayer, Todo.defaultLayer, WorkflowService.defaultLayer]), Layer.provide(Skill.defaultLayer), Layer.provide(Agent.defaultLayer), - Layer.provide(Session.defaultLayer), + Layer.provide([Session.defaultLayer, AgentCommand.defaultLayer]), Layer.provide([SessionStatus.defaultLayer, BackgroundTask.defaultLayer]), Layer.provide(Provider.defaultLayer), Layer.provide(LSP.defaultLayer), diff --git a/src/mendcode/packages/opencode/src/tool/tell.ts b/src/mendcode/packages/opencode/src/tool/tell.ts new file mode 100644 index 00000000..b7bb9d9e --- /dev/null +++ b/src/mendcode/packages/opencode/src/tool/tell.ts @@ -0,0 +1,65 @@ +import { Effect, Schema } from "effect" +import * as Tool from "./tool" +import DESCRIPTION from "./tell.txt" +import { AgentCommand } from "@/session/agent-command" +import { AgentCommandPolicy } from "@/session/agent-command-policy" +import { SessionID } from "@/session/schema" + +export const Parameters = Schema.Struct({ + targetSessionID: SessionID.annotate({ + description: "Exact target session ID. Do not guess or resolve an ambiguous title.", + }), + text: Schema.String.annotate({ + description: + "Plain-text message to queue. Keep it focused; files, reasoning, permissions, and history are not included.", + }), +}) + +type Metadata = { + peerMessage: { + commandID: string + targetSessionID: string + state: AgentCommand.State + policy: AgentCommandPolicy.Decision + } +} + +export const TellTool = Tool.define( + "tell", + Effect.gen(function* () { + const commands = yield* AgentCommand.Service + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const command = yield* commands + .create({ + sourceSessionID: ctx.sessionID, + targetSessionID: params.targetSessionID, + type: "peer_message", + payload: { text: params.text }, + }) + .pipe(Effect.orDie) + return { + title: `Peer message → ${command.targetSessionID}`, + output: [ + `Queued peer message ${command.id}.`, + `target: ${command.targetSessionID}`, + `state: ${command.state}`, + `policy: ${command.policy.decision}`, + "The target must explicitly accept it before delivery.", + ].join("\n"), + metadata: { + peerMessage: { + commandID: command.id, + targetSessionID: command.targetSessionID, + state: command.state, + policy: command.policy.decision, + }, + }, + } + }), + } + }), +) diff --git a/src/mendcode/packages/opencode/src/tool/tell.txt b/src/mendcode/packages/opencode/src/tool/tell.txt new file mode 100644 index 00000000..993ae288 --- /dev/null +++ b/src/mendcode/packages/opencode/src/tool/tell.txt @@ -0,0 +1,3 @@ +Send a plain-text message to one explicit MendCode session in the same project. + +This queues an auditable peer message for the target session. It never shares history, files, reasoning, permissions, or model context. The target must explicitly accept the message before it is delivered to its prompt queue; if the target is busy, delivery waits for its next idle boundary. diff --git a/src/mendcode/packages/opencode/test/agent/agent.test.ts b/src/mendcode/packages/opencode/test/agent/agent.test.ts index be5a9719..35da56c7 100644 --- a/src/mendcode/packages/opencode/test/agent/agent.test.ts +++ b/src/mendcode/packages/opencode/test/agent/agent.test.ts @@ -8,6 +8,7 @@ import { Agent } from "../../src/agent/agent" import { Config } from "../../src/config/config" import { Permission } from "../../src/permission" import { Global } from "@mendcode/core/global" +import { AppFileSystem } from "@mendcode/core/filesystem" import { resolvePlanExitAgent } from "../../src/tool/plan" // Helper to evaluate permission for a tool with wildcard pattern @@ -571,6 +572,23 @@ test("global tmp directory children are allowed for external_directory", async ( }) }) +test.skipIf(process.platform === "win32")( + "canonical global tmp aliases are allowed for external_directory", + async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const build = await load(tmp.path, (svc) => svc.get("build")) + const canonicalTmp = AppFileSystem.resolve(Global.Path.tmp) + expect(Permission.evaluate("external_directory", path.join(canonicalTmp, "*"), build!.permission).action).toBe( + "allow", + ) + }, + }) + }, +) + test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => { const { Truncate } = await import("../../src/tool/truncate") await using tmp = await tmpdir({ diff --git a/src/mendcode/packages/opencode/test/cli/cmd/tui/sync.test.tsx b/src/mendcode/packages/opencode/test/cli/cmd/tui/sync.test.tsx index ae51f449..3706506a 100644 --- a/src/mendcode/packages/opencode/test/cli/cmd/tui/sync.test.tsx +++ b/src/mendcode/packages/opencode/test/cli/cmd/tui/sync.test.tsx @@ -19,11 +19,7 @@ import { TUI_SESSION_MESSAGE_SYNC_LIMIT, useSync, } from "../../../../src/cli/cmd/tui/context/sync" -import { - SyncProviderV2, - TUI_V2_SESSION_CACHE_LIMIT, - useSyncV2, -} from "../../../../src/cli/cmd/tui/context/sync-v2" +import { SyncProviderV2, TUI_V2_SESSION_CACHE_LIMIT, useSyncV2 } from "../../../../src/cli/cmd/tui/context/sync-v2" import { tmpdir } from "../../../fixture/fixture" const worktree = "/tmp/opencode" @@ -333,8 +329,8 @@ describe("tui sync", () => { await Bun.write(`${tmp.path}/kv.json`, "{}") let available = true const unavailable = () => { - if (available) return [{ id: "ses_persisted" }] - throw new Error("simulated network loss") + if (available) return [{ id: "ses_persisted" }] + throw new Error("simulated network loss") } const { app, sync } = await mount({ "/session": unavailable, @@ -1138,6 +1134,72 @@ describe("tui sync", () => { } }) + test("session sync does not block transcript updates on a slow diff", async () => { + const previous = Global.Path.state + await using tmp = await tmpdir() + Global.Path.state = tmp.path + await Bun.write(`${tmp.path}/kv.json`, "{}") + + const sessionID = "ses_slow_diff" + const message = { + info: { + id: "msg_slow_diff", + sessionID, + role: "assistant", + agent: "build", + providerID: "openai", + modelID: "gpt-test", + tokens: {}, + time: { created: 1 }, + }, + parts: [{ id: "prt_slow_diff", messageID: "msg_slow_diff", sessionID, type: "text", text: "live" }], + } + let diffCalls = 0 + let busy = true + let releaseDiff: (() => void) | undefined + const { app, sync } = await mount({ + [`/session/${sessionID}`]: { + id: sessionID, + projectID: "proj_test", + directory, + title: "Slow diff", + version: "test", + time: { created: 1, updated: 1 }, + }, + [`/session/${sessionID}/message`]: [message], + [`/session/${sessionID}/todo`]: [], + "/session/status": () => (busy ? { [sessionID]: { type: "busy" } } : {}), + [`/session/${sessionID}/diff`]: async () => { + diffCalls++ + await new Promise((resolve) => (releaseDiff = resolve)) + return [{ file: "large.diff", additions: 1, deletions: 0 }] + }, + }) + + try { + await sync.session.sync(sessionID, { force: true }) + + expect(sync.data.message[sessionID]?.map((item) => item.id)).toEqual([message.info.id]) + expect(sync.data.part[message.info.id]?.[0]).toMatchObject({ type: "text", text: "live" }) + expect(sync.data.session_diff[sessionID]).toBeUndefined() + expect(diffCalls).toBe(0) + + busy = false + await sync.session.sync(sessionID, { force: true }) + expect(diffCalls).toBe(1) + + await sync.session.sync(sessionID, { force: true }) + expect(diffCalls).toBe(1) + + releaseDiff?.() + await wait(() => sync.data.session_diff[sessionID]?.[0]?.file === "large.diff") + } finally { + releaseDiff?.() + app.renderer.destroy() + Global.Path.state = previous + } + }) + test("coalesces concurrent forced session refreshes and runs one queued refresh", async () => { const previous = Global.Path.state await using tmp = await tmpdir() @@ -2583,12 +2645,12 @@ describe("tui sync", () => { } as GlobalEvent emit(userEvent) - await wait(() => sync.data.message[sessionID]?.some((message) => message.id === optimisticUser.id) === true) - emit(assistantEvent) + await wait(() => sync.data.message[sessionID]?.some((message) => message.id === optimisticUser.id) === true) + emit(assistantEvent) - await wait(() => sync.data.message[sessionID]?.some((message) => message.id === nextAssistant.id) === true) - const messages = sync.data.message[sessionID] ?? [] - expect(messages).toHaveLength(TUI_SESSION_MESSAGE_STORE_LIMIT) + await wait(() => sync.data.message[sessionID]?.some((message) => message.id === nextAssistant.id) === true) + const messages = sync.data.message[sessionID] ?? [] + expect(messages).toHaveLength(TUI_SESSION_MESSAGE_STORE_LIMIT) expect(messages.map((message) => message.id)).toHaveLength(new Set(messages.map((message) => message.id)).size) expect(messages.some((message) => message.id === previousAssistant.id)).toBe(true) @@ -3794,13 +3856,16 @@ describe("tui sync", () => { test("v2 event projection evicts old session caches", async () => { let emit!: (event: GlobalEvent) => void - const { app, sync } = await mountV2({}, { - events: eventSource({ - onSubscribe: (handler) => { - emit = handler - }, - }), - }) + const { app, sync } = await mountV2( + {}, + { + events: eventSource({ + onSubscribe: (handler) => { + emit = handler + }, + }), + }, + ) try { for (let index = 0; index < TUI_V2_SESSION_CACHE_LIMIT + 3; index++) { diff --git a/src/mendcode/packages/opencode/test/cli/tui/agent-view.test.ts b/src/mendcode/packages/opencode/test/cli/tui/agent-view.test.ts index 9d06348d..e86acbcf 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/agent-view.test.ts +++ b/src/mendcode/packages/opencode/test/cli/tui/agent-view.test.ts @@ -145,10 +145,31 @@ describe("Agent View visibility", () => { { rootSessionID: undefined }, ]) - expect(isAgentViewLoopSession({ sessionID: "ses_paused_manual", title: "A normal-looking title", loopRootSessionIDs: roots })).toBe(true) - expect(isAgentViewLoopSession({ sessionID: "ses_old_manual", title: "Another title", loopRootSessionIDs: roots })).toBe(true) - expect(isAgentViewLoopSession({ sessionID: "ses_title_fallback", title: "Loop: legacy root", loopRootSessionIDs: roots })).toBe(true) - expect(isAgentViewLoopSession({ sessionID: "ses_summary_fallback", title: "legacy root", summary: "Loop paused: paused", loopRootSessionIDs: roots })).toBe(true) + expect( + isAgentViewLoopSession({ + sessionID: "ses_paused_manual", + title: "A normal-looking title", + loopRootSessionIDs: roots, + }), + ).toBe(true) + expect( + isAgentViewLoopSession({ sessionID: "ses_old_manual", title: "Another title", loopRootSessionIDs: roots }), + ).toBe(true) + expect( + isAgentViewLoopSession({ + sessionID: "ses_title_fallback", + title: "Loop: legacy root", + loopRootSessionIDs: roots, + }), + ).toBe(true) + expect( + isAgentViewLoopSession({ + sessionID: "ses_summary_fallback", + title: "legacy root", + summary: "Loop paused: paused", + loopRootSessionIDs: roots, + }), + ).toBe(true) }) test("filters loop roots out of the regular session list", () => { @@ -193,7 +214,9 @@ describe("Agent View visibility", () => { test("identifies completed loop sessions from title or workflow roots", () => { const roots = agentViewLoopRootSessionIDs([{ rootSessionID: "ses_root" }]) - expect(isAgentViewCompletedLoopSession({ sessionID: "ses_title", title: "Loop: complete me", state: "completed" })).toBe(true) + expect( + isAgentViewCompletedLoopSession({ sessionID: "ses_title", title: "Loop: complete me", state: "completed" }), + ).toBe(true) expect( isAgentViewCompletedLoopSession({ sessionID: "ses_summary", @@ -202,8 +225,17 @@ describe("Agent View visibility", () => { summary: "Loop completed: completed", }), ).toBe(true) - expect(isAgentViewCompletedLoopSession({ sessionID: "ses_root", title: "Manual loop", state: "completed", loopRootSessionIDs: roots })).toBe(true) - expect(isAgentViewCompletedLoopSession({ sessionID: "ses_chat", title: "Normal chat", state: "completed" })).toBe(false) + expect( + isAgentViewCompletedLoopSession({ + sessionID: "ses_root", + title: "Manual loop", + state: "completed", + loopRootSessionIDs: roots, + }), + ).toBe(true) + expect(isAgentViewCompletedLoopSession({ sessionID: "ses_chat", title: "Normal chat", state: "completed" })).toBe( + false, + ) }) test("hides completed temp sessions but keeps active or awaiting rows", () => { @@ -241,14 +273,18 @@ describe("Agent View visibility", () => { }) test("keeps pinned or active archived sessions visible while hiding archived completed rows", () => { - expect(isAgentViewSessionVisible({ item: item({ state: "completed", metadata: { archived: true } }), now })).toBe(false) + expect(isAgentViewSessionVisible({ item: item({ state: "completed", metadata: { archived: true } }), now })).toBe( + false, + ) expect( isAgentViewSessionVisible({ item: item({ state: "completed", metadata: { archived: true, pinned: true } }), now, }), ).toBe(true) - expect(isAgentViewSessionVisible({ item: item({ state: "working", metadata: { archived: true } }), now })).toBe(true) + expect(isAgentViewSessionVisible({ item: item({ state: "working", metadata: { archived: true } }), now })).toBe( + true, + ) }) test("allows old real sessions only as the empty-recent fallback", () => { @@ -270,24 +306,44 @@ describe("Agent View visibility", () => { }) test("summarizes and counts command inbox state for Agent View rows", () => { - const pending = command({ id: "acmd_1", targetSessionID: "ses_worker", type: "rename", payload: { title: "Worker Alpha" } }) - const accepted = command({ id: "acmd_2", targetSessionID: "ses_worker", state: "accepted", type: "tag", payload: { tags: ["api"] } }) + const pending = command({ + id: "acmd_1", + targetSessionID: "ses_worker", + type: "rename", + payload: { title: "Worker Alpha" }, + }) + const accepted = command({ + id: "acmd_2", + targetSessionID: "ses_worker", + state: "accepted", + type: "tag", + payload: { tags: ["api"] }, + }) const other = command({ id: "acmd_3", targetSessionID: "ses_other" }) expect(isAgentViewCommandActionable(pending)).toBe(true) expect(isAgentViewCommandActionable(accepted)).toBe(false) expect(agentViewCommandTouchesSession({ command: pending, sessionID: "ses_worker" })).toBe(true) expect(agentViewCommandTouchesSession({ command: pending, sessionID: "ses_source" })).toBe(false) - expect(agentViewCommandTouchesSession({ command: pending, sessionID: "ses_source", direction: "either" })).toBe(true) + expect(agentViewCommandTouchesSession({ command: pending, sessionID: "ses_source", direction: "either" })).toBe( + true, + ) expect(agentViewCommandTouchesSession({ command: pending, sessionID: "ses_else", direction: "either" })).toBe(false) - expect(countAgentViewCommands({ commands: [pending, accepted, other], sessionID: "ses_worker", states: ["pending"] })).toBe(1) + expect( + countAgentViewCommands({ commands: [pending, accepted, other], sessionID: "ses_worker", states: ["pending"] }), + ).toBe(1) expect(countAgentViewCommands({ commands: [pending, accepted, other], sessionID: "ses_worker" })).toBe(2) - expect(countAgentViewCommands({ commands: [pending, accepted, other], sessionID: "ses_source", direction: "either" })).toBe(3) + expect( + countAgentViewCommands({ commands: [pending, accepted, other], sessionID: "ses_source", direction: "either" }), + ).toBe(3) expect(formatAgentViewCommandSummary(pending)).toBe("pending · rename row · Worker Alpha") expect(formatAgentViewCommandSummary(accepted)).toBe("accepted · update tags · #api") - expect(formatAgentViewCommandSummary(command({ type: "send_message", payload: { text: "Ship next safe step" } }))).toBe( - "pending · send message · Ship next safe step", - ) + expect( + formatAgentViewCommandSummary(command({ type: "send_message", payload: { text: "Ship next safe step" } })), + ).toBe("pending · send message · Ship next safe step") + expect( + formatAgentViewCommandSummary(command({ type: "peer_message", payload: { text: "Check the queued run" } })), + ).toBe("pending · peer message · Check the queued run") expect(formatAgentViewCommandSummary(command({ type: "stop", payload: { reason: "handoff" } }))).toBe( "pending · stop worker · handoff", ) diff --git a/src/mendcode/packages/opencode/test/cli/tui/permission-prompt.test.ts b/src/mendcode/packages/opencode/test/cli/tui/permission-prompt.test.ts index 1bd0fdba..587502b1 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/permission-prompt.test.ts +++ b/src/mendcode/packages/opencode/test/cli/tui/permission-prompt.test.ts @@ -3,10 +3,18 @@ import { permissionPromptHoverSelection } from "@/cli/cmd/tui/routes/session/per describe("permission prompt pointer selection", () => { test("does not let a synthetic hover undo keyboard navigation", () => { - expect(permissionPromptHoverSelection("over", "always", "once")).toBe("always") + const pointer = { keyboardNavigation: true, x: 12, y: 5 } + expect(permissionPromptHoverSelection({ type: "over", x: 12, y: 5 }, "always", "once", pointer)).toBe("always") }) - test("follows the hovered option after a real mouse move", () => { - expect(permissionPromptHoverSelection("move", "always", "once")).toBe("once") + test("does not let a parked cursor move replay undo keyboard navigation", () => { + const pointer = { keyboardNavigation: true, x: 12, y: 5 } + expect(permissionPromptHoverSelection({ type: "move", x: 12, y: 5 }, "always", "once", pointer)).toBe("always") + }) + + test("follows the hovered option after a physical mouse move", () => { + const pointer = { keyboardNavigation: true, x: 12, y: 5 } + expect(permissionPromptHoverSelection({ type: "move", x: 12, y: 7 }, "always", "once", pointer)).toBe("once") + expect(pointer.keyboardNavigation).toBe(false) }) }) diff --git a/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts b/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts index 5083b6f5..6aa2f333 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts +++ b/src/mendcode/packages/opencode/test/cli/tui/prompt-working-start.test.ts @@ -39,7 +39,9 @@ import { shouldKeepWorkingStatus, shouldEnableSessionInterrupt, shouldHandlePromptCursorArrow, - shouldInterruptImmediately, + hasInterruptibleSessionTurn, + sessionInterruptConfirmationAction, + sessionInterruptHint, clipboardPasteAction, shouldPreferMessagePromptHistory, storedAssistantDeliveryState, @@ -94,6 +96,7 @@ import { statsDayTokenValue, statsDayVisualValue, statsGraphSeries, + normalizeStatsWidgets, statsSelectedDayIndex, usageInsightsCacheKey, } from "@/cli/cmd/tui/routes/stats" @@ -140,9 +143,11 @@ describe("prompt input event priority", () => { expect(clipboardPasteAction(undefined)).toBe("none") }) - test("keeps Esc immediate while a clipboard probe is pending", () => { - expect(shouldInterruptImmediately({ statusType: "busy", hasActiveWorkingAssistant: true })).toBe(true) - expect(shouldEnableSessionInterrupt({ statusType: "busy", autocompleteVisible: false, promptFocused: false })).toBe(true) + test("keeps Esc handling available while a clipboard probe is pending", () => { + expect(hasInterruptibleSessionTurn({ statusType: "busy", hasActiveWorkingAssistant: true })).toBe(true) + expect(shouldEnableSessionInterrupt({ statusType: "busy", autocompleteVisible: false, promptFocused: false })).toBe( + true, + ) }) }) @@ -584,7 +589,9 @@ describe("queued user turn", () => { const messages = [{ id: "msg_001", role: "assistant", time: { created: 1_000 } }] expect(latestPendingAssistantID(messages, { now: 100_000 })).toBeUndefined() expect(latestPendingAssistantID(messages, { statusType: "busy", now: 100_000 })).toBeUndefined() - expect(latestPendingAssistantID(messages, { statusType: "busy", now: 100_000, statusUntil: 101_000 })).toBe("msg_001") + expect(latestPendingAssistantID(messages, { statusType: "busy", now: 100_000, statusUntil: 101_000 })).toBe( + "msg_001", + ) expect(latestPendingAssistantID(messages, { statusType: "idle", now: 100_000 })).toBeUndefined() }) @@ -906,9 +913,9 @@ describe("resolveWorkingStartedAt", () => { expect(shouldClearWorkingStartedAt({ statusType: "busy" })).toBe(false) expect(shouldClearWorkingStartedAt({ statusType: "busy", interrupted: true })).toBe(true) expect(shouldClearWorkingStartedAt({ statusType: "busy", terminalAssistant: true })).toBe(true) - expect( - shouldClearWorkingStartedAt({ statusType: "busy", terminalAssistant: true, permissionPending: true }), - ).toBe(false) + expect(shouldClearWorkingStartedAt({ statusType: "busy", terminalAssistant: true, permissionPending: true })).toBe( + false, + ) }) test("keeps interrupt enabled for orphaned unfinished assistant messages", () => { @@ -920,6 +927,7 @@ describe("resolveWorkingStartedAt", () => { expect(shouldEnableSessionInterrupt({ statusType: "idle" })).toBe(false) expect(shouldEnableSessionInterrupt({ statusType: "idle", hasActiveWorkingAssistant: true })).toBe(true) expect(shouldEnableSessionInterrupt({ statusType: "idle", hasPendingPromptDelivery: true })).toBe(true) + expect(shouldEnableSessionInterrupt({ statusType: "idle", compactionActive: true })).toBe(true) expect(shouldEnableSessionInterrupt({ statusType: "busy", promptFocused: false })).toBe(true) expect(shouldEnableSessionInterrupt({ statusType: "busy", promptFocused: true })).toBe(true) expect(shouldEnableSessionInterrupt({ statusType: "busy", autocompleteVisible: true })).toBe(true) @@ -932,20 +940,41 @@ describe("resolveWorkingStartedAt", () => { ).toBe(true) }) - test("interrupts an active turn immediately without discarding the local draft", () => { - expect(shouldInterruptImmediately({ statusType: "busy" })).toBe(true) - expect(shouldInterruptImmediately({ statusType: "busy", compactionActive: true })).toBe(true) - expect(shouldInterruptImmediately({ statusType: "retry" })).toBe(true) - expect(shouldInterruptImmediately({ statusType: "busy", hasDraft: true })).toBe(true) - expect(shouldInterruptImmediately({ statusType: "retry", hasDraft: true })).toBe(true) - expect(shouldInterruptImmediately({ statusType: "idle" })).toBe(false) - expect(shouldInterruptImmediately({ statusType: "idle", hasActiveWorkingAssistant: true })).toBe(true) - expect(shouldInterruptImmediately({ statusType: "idle", hasPendingPromptDelivery: true })).toBe(true) - expect(shouldInterruptImmediately({ statusType: "idle", hasActiveWorkingAssistant: true, hasDraft: true })).toBe( + test("recognizes an active turn without discarding the local draft", () => { + expect(hasInterruptibleSessionTurn({ statusType: "busy" })).toBe(true) + expect(hasInterruptibleSessionTurn({ statusType: "busy", compactionActive: true })).toBe(true) + expect(hasInterruptibleSessionTurn({ statusType: "retry" })).toBe(true) + expect(hasInterruptibleSessionTurn({ statusType: "busy", hasDraft: true })).toBe(true) + expect(hasInterruptibleSessionTurn({ statusType: "retry", hasDraft: true })).toBe(true) + expect(hasInterruptibleSessionTurn({ statusType: "idle" })).toBe(false) + expect(hasInterruptibleSessionTurn({ statusType: "idle", hasActiveWorkingAssistant: true })).toBe(true) + expect(hasInterruptibleSessionTurn({ statusType: "idle", hasPendingPromptDelivery: true })).toBe(true) + expect(hasInterruptibleSessionTurn({ statusType: "idle", compactionActive: true })).toBe(true) + expect(hasInterruptibleSessionTurn({ statusType: "idle", hasActiveWorkingAssistant: true, hasDraft: true })).toBe( true, ) }) + test("requires two Esc presses for the same active turn", () => { + expect(sessionInterruptConfirmationAction({ activeTargetMessageID: "message-1" })).toBe("arm") + expect( + sessionInterruptConfirmationAction({ + activeTargetMessageID: "message-1", + armedTargetMessageID: "message-1", + }), + ).toBe("interrupt") + expect( + sessionInterruptConfirmationAction({ + activeTargetMessageID: "message-2", + armedTargetMessageID: "message-1", + }), + ).toBe("arm") + expect(sessionInterruptConfirmationAction({ armedTargetMessageID: "message-1" })).toBe("ignore") + expect(sessionInterruptHint({ enabled: true, working: true, armed: false })).toBe("[esc to interrupt]") + expect(sessionInterruptHint({ enabled: true, working: true, armed: true })).toBe("[esc again to interrupt]") + expect(sessionInterruptHint({ enabled: true, working: false, armed: true })).toBeUndefined() + }) + test("accepts prompt interrupt only when the prompt textarea owns focus", () => { const promptInput = {} const arcadeInput = {} @@ -1418,7 +1447,10 @@ describe("resolveWorkingStartedAt", () => { expect(loopWakeupLabel(blocked)).toBe("not scheduled (blocked)") expect(loopWakeupLabel(waiting)).toBe("waiting for external-signal") - expect(loopStateCounts([{ state: "sleeping" }, { state: "blocked" }, { state: "blocked" }])).toEqual({ scheduled: 1, blocked: 2 }) + expect(loopStateCounts([{ state: "sleeping" }, { state: "blocked" }, { state: "blocked" }])).toEqual({ + scheduled: 1, + blocked: 2, + }) }) test("updates loop wakeup countdowns from the caller clock", () => { @@ -1529,6 +1561,22 @@ describe("resolveWorkingStartedAt", () => { expect(statsSelectedDayIndex({ days: freshDays, selectedDay: "2026-07-02", selectedIndex: 1 })).toBe(2) expect(statsSelectedDayIndex({ days: freshDays, selectedDay: "2026-07-03", selectedIndex: 2 })).toBe(3) expect(statsSelectedDayIndex({ days: freshDays, selectedIndex: 2 })).toBe(2) + expect( + statsSelectedDayIndex({ + days: freshDays, + selectedDay: "2026-07-03", + selectedIndex: 2, + followLatest: true, + }), + ).toBe(4) + }) + + test("normalizes persisted stats widgets without unknown or duplicate entries", () => { + expect(normalizeStatsWidgets(["clock", "clock", "subscription-usage", "unknown"])).toEqual([ + "clock", + "subscription-usage", + ]) + expect(normalizeStatsWidgets(undefined)).toContain("subscription-usage") }) test("refreshes stale stats cache when the latest cached day is behind today", () => { @@ -1773,15 +1821,15 @@ describe("resolveWorkingStartedAt", () => { }) test("routes Esc to the active session independently of prompt focus", () => { - expect( - shouldHandleGlobalSessionInterrupt({ eventName: "escape", activeTurn: true, pendingInput: false }), - ).toBe(true) - expect( - shouldHandleGlobalSessionInterrupt({ eventName: "escape", activeTurn: true, pendingInput: true }), - ).toBe(false) - expect( - shouldHandleGlobalSessionInterrupt({ eventName: "escape", activeTurn: false, pendingInput: false }), - ).toBe(false) + expect(shouldHandleGlobalSessionInterrupt({ eventName: "escape", activeTurn: true, pendingInput: false })).toBe( + true, + ) + expect(shouldHandleGlobalSessionInterrupt({ eventName: "escape", activeTurn: true, pendingInput: true })).toBe( + false, + ) + expect(shouldHandleGlobalSessionInterrupt({ eventName: "escape", activeTurn: false, pendingInput: false })).toBe( + false, + ) expect( shouldHandleGlobalSessionInterrupt({ eventName: "escape", @@ -1811,14 +1859,15 @@ describe("resolveWorkingStartedAt", () => { }) test("does not detach an already-following bottom viewport during newer-page reconciliation", () => { + expect(sessionBottomFollowMode({ alreadyFollowing: true, hasMoreNewer: true, loadingNewer: false })).toBe("follow") + expect(sessionBottomFollowMode({ alreadyFollowing: false, hasMoreNewer: true, loadingNewer: false })).toBe("page") expect( - sessionBottomFollowMode({ alreadyFollowing: true, hasMoreNewer: true, loadingNewer: false }), - ).toBe("follow") - expect( - sessionBottomFollowMode({ alreadyFollowing: false, hasMoreNewer: true, loadingNewer: false }), - ).toBe("page") - expect( - sessionBottomFollowMode({ alreadyFollowing: true, hasMoreNewer: true, loadingNewer: false, suppressedBoundary: "bottom" }), + sessionBottomFollowMode({ + alreadyFollowing: true, + hasMoreNewer: true, + loadingNewer: false, + suppressedBoundary: "bottom", + }), ).toBe("follow") }) diff --git a/src/mendcode/packages/opencode/test/cli/tui/reasoning-layout.test.tsx b/src/mendcode/packages/opencode/test/cli/tui/reasoning-layout.test.tsx new file mode 100644 index 00000000..48a2c4ec --- /dev/null +++ b/src/mendcode/packages/opencode/test/cli/tui/reasoning-layout.test.tsx @@ -0,0 +1,41 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { SyntaxStyle, type ScrollBoxRenderable } from "@opentui/core" +import { testRender } from "@opentui/solid" +import { reasoningViewportHeight } from "../../../src/mend/tui/presentation" + +test("short reasoning scrollboxes size to their content instead of the max viewport", async () => { + const syntaxStyle = SyntaxStyle.create() + let scrollbox: ScrollBoxRenderable | undefined + const app = await testRender( + () => ( + (scrollbox = value)} + height={reasoningViewportHeight("short", 14)} + maxHeight={14} + minHeight={1} + stickyScroll={false} + > + + + ), + { width: 80, height: 30 }, + ) + + try { + await Bun.sleep(20) + await app.renderOnce() + expect(scrollbox).toBeDefined() + expect(scrollbox!.height).toBeGreaterThan(0) + expect(scrollbox!.height).toBeLessThan(14) + } finally { + app.renderer.destroy() + syntaxStyle.destroy() + } +}) diff --git a/src/mendcode/packages/opencode/test/cli/tui/session-initialization.test.ts b/src/mendcode/packages/opencode/test/cli/tui/session-initialization.test.ts index 208082e3..e4d8ac12 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/session-initialization.test.ts +++ b/src/mendcode/packages/opencode/test/cli/tui/session-initialization.test.ts @@ -1,13 +1,38 @@ import { describe, expect, test } from "bun:test" describe("session route initialization", () => { + test("does not render an empty-editor preflight frame before the optimistic turn exists", async () => { + const source = await Bun.file( + new URL("../../../src/cli/cmd/tui/component/prompt/index.tsx", import.meta.url), + ).text() + const submitStart = source.indexOf("async function submit()") + const submitEnd = source.indexOf("function applyExpandedPastedText", submitStart) + const submit = source.slice(submitStart, submitEnd) + + expect(submit.indexOf("await budgetEnforcementStatus")).toBeLessThan(submit.indexOf("clearPromptForSubmit()")) + expect(submit.indexOf("await findSkill")).toBeLessThan(submit.indexOf("clearPromptForSubmit()")) + expect(submit.indexOf("clearPromptForSubmit()")).toBeLessThan(submit.indexOf("insertOptimisticUserTurn({")) + expect(submit.indexOf("props.onSubmit?.({")).toBeLessThan(submit.indexOf("renderer.requestRender()")) + }) + + test("does not overlap expensive topbar git stats refreshes", async () => { + const source = await Bun.file(new URL("../../../src/cli/cmd/tui/routes/session/index.tsx", import.meta.url)).text() + + expect(source).toContain("if (topStats.loading) return") + expect(source).toContain("void refetchTopStats()") + expect(source).toContain("}, 15_000)") + expect(source).not.toContain("setTopStatsTick") + }) + test("declares keybind before top navigation memos read it", async () => { const source = await Bun.file(new URL("../../../src/cli/cmd/tui/routes/session/index.tsx", import.meta.url)).text() const declaration = "const keybind = useKeybind()" const firstDeclarationIndex = source.indexOf(declaration) expect(firstDeclarationIndex).toBeGreaterThan(-1) - expect(source.indexOf(declaration, firstDeclarationIndex + declaration.length)).toBeGreaterThan(firstDeclarationIndex) + expect(source.indexOf(declaration, firstDeclarationIndex + declaration.length)).toBeGreaterThan( + firstDeclarationIndex, + ) expect(firstDeclarationIndex).toBeLessThan(source.indexOf('keybind.print("session_parent")')) }) @@ -63,12 +88,14 @@ describe("session route initialization", () => { expect(restore).toContain("restoreScrollAnchor({ preserveMissing: true })") expect(source).toContain("const pagingRestoreToken = scrollPagingRestoreToken") expect(source).toContain("pagingRestoreToken !== scrollPagingRestoreToken") - expect(source).toContain("bottomFollowMode === \"follow\" && pagingRestoreToken === scrollPagingRestoreToken") + expect(source).toContain('bottomFollowMode === "follow" && pagingRestoreToken === scrollPagingRestoreToken') expect(source).toContain("cancelScrollPagingRestore()\n suppressedPagingBoundary = undefined") }) test("uses the stable transcript tail for prompt activity while paging older history", async () => { - const source = await Bun.file(new URL("../../../src/cli/cmd/tui/component/prompt/index.tsx", import.meta.url)).text() + const source = await Bun.file( + new URL("../../../src/cli/cmd/tui/component/prompt/index.tsx", import.meta.url), + ).text() expect(source).toContain("const messagesForActivity = createMemo") expect(source).toContain("latestAssistant: sync.data.session_latest_assistant[sessionID]") @@ -76,10 +103,18 @@ describe("session route initialization", () => { }) test("publishes prompt delivery handoff state without a navigation timer", async () => { - const source = await Bun.file(new URL("../../../src/cli/cmd/tui/component/prompt/index.tsx", import.meta.url)).text() + const source = await Bun.file( + new URL("../../../src/cli/cmd/tui/component/prompt/index.tsx", import.meta.url), + ).text() - const begin = source.slice(source.indexOf("function beginPendingPromptDelivery"), source.indexOf("function endPendingPromptDelivery")) - const end = source.slice(source.indexOf("function endPendingPromptDelivery"), source.indexOf("function cancelPendingPromptDeliveryForInterrupt")) + const begin = source.slice( + source.indexOf("function beginPendingPromptDelivery"), + source.indexOf("function endPendingPromptDelivery"), + ) + const end = source.slice( + source.indexOf("function endPendingPromptDelivery"), + source.indexOf("function cancelPendingPromptDeliveryForInterrupt"), + ) expect(begin).toContain("notifyPendingPromptDeliveryListeners()") expect(end).toContain("if (removed) notifyPendingPromptDeliveryListeners()") expect(source.match(/pendingPromptDeliveryRevision\(\)/g)?.length).toBeGreaterThanOrEqual(3) @@ -87,12 +122,14 @@ describe("session route initialization", () => { expect(source.indexOf("const queuedBehindActiveTurn = workingStatusActive()")).toBeLessThan( source.indexOf("setSubmitPreflightActive(true)"), ) - expect(source).toContain("route.navigate({\n type: \"session\",") + expect(source).toContain('route.navigate({\n type: "session",') expect(source).not.toContain("setTimeout(() => {\n route.navigate({") }) test("keeps stop acknowledgements in backend control without rendering frontend status copy", async () => { - const frontend = await Bun.file(new URL("../../../src/cli/cmd/tui/component/prompt/index.tsx", import.meta.url)).text() + const frontend = await Bun.file( + new URL("../../../src/cli/cmd/tui/component/prompt/index.tsx", import.meta.url), + ).text() const backend = await Bun.file(new URL("../../../src/session/prompt.ts", import.meta.url)).text() expect(frontend).not.toContain('return "stop requested"') @@ -111,12 +148,11 @@ describe("session route initialization", () => { new URL("../../../src/cli/cmd/tui/feature-plugins/system/session-v2.tsx", import.meta.url), ).text() - expect(source).toContain("const sessionScrollStates = new Map()") - expect(source).toContain("const scheduleSessionScrollRestore = (sessionID: string, state?: SessionScrollState) =>") - expect(source).toContain("const navigate = (...args: Parameters) =>") - expect(source).toContain("return navigateRoute(...args)") - expect(source).toContain("if (activeSessionID !== sessionID) rememberSessionScroll(activeSessionID)") - + expect(source).toContain("const sessionScrollStates = new Map()") + expect(source).toContain("const scheduleSessionScrollRestore = (sessionID: string, state?: SessionScrollState) =>") + expect(source).toContain("const navigate = (...args: Parameters) =>") + expect(source).toContain("return navigateRoute(...args)") + expect(source).toContain("if (activeSessionID !== sessionID) rememberSessionScroll(activeSessionID)") expect(source).toContain("onMouseUp={handleOpenTarget}") expect(sessionV2).toContain("const handleOpenFirstLoop = () =>") @@ -140,7 +176,9 @@ describe("session route initialization", () => { }) test("renders workflow tasks in the phase-ordered monitor rows", async () => { - const source = await Bun.file(new URL("../../../src/cli/cmd/tui/routes/workflows/index.tsx", import.meta.url)).text() + const source = await Bun.file( + new URL("../../../src/cli/cmd/tui/routes/workflows/index.tsx", import.meta.url), + ).text() expect(source).toContain("const taskRows = createMemo") expect(source).toContain("") @@ -148,7 +186,9 @@ describe("session route initialization", () => { }) test("retries failed workflow work when resume is requested", async () => { - const source = await Bun.file(new URL("../../../src/cli/cmd/tui/routes/workflows/index.tsx", import.meta.url)).text() + const source = await Bun.file( + new URL("../../../src/cli/cmd/tui/routes/workflows/index.tsx", import.meta.url), + ).text() expect(source).toContain("const target = workflowMonitorResumeTarget(receipt(item))") expect(source).toContain('if (target.kind === "retry-task")') @@ -156,7 +196,9 @@ describe("session route initialization", () => { }) test("offers workflow sessions the same permission modes and global default controls", async () => { - const source = await Bun.file(new URL("../../../src/cli/cmd/tui/routes/workflows/index.tsx", import.meta.url)).text() + const source = await Bun.file( + new URL("../../../src/cli/cmd/tui/routes/workflows/index.tsx", import.meta.url), + ).text() expect(source).toContain('title: "Require approval"') expect(source).toContain('title: "Smart Approval"') @@ -242,7 +284,7 @@ describe("session route initialization", () => { expect(source).toContain('return "Waiting for the current response to finish"') expect(source).toContain("const transcriptRows = createMemo") expect(source).toContain("return sessionTranscriptRows(messages(), queuedMessageIDs(), {") - expect(source).toContain("return messages().filter((message): message is UserMessage => message.role === \"user\"") + expect(source).toContain('return messages().filter((message): message is UserMessage => message.role === "user"') expect(source).toContain("tailIDs: pendingDeliveryTailIDs()") expect(source).toContain("const pendingDeliveryQueuedIDs = createMemo") expect(source).toContain("pendingPromptDeliveryMessageIDs(route.sessionID)") @@ -250,10 +292,10 @@ describe("session route initialization", () => { expect(source).toContain("") expect(source).toContain("messageByID().get(messageID)") expect(source).toContain("queuedMessageIDs().has(message().id)") - expect(source).toContain('anchorID={`queued-${message().id}`}') + expect(source).toContain("anchorID={`queued-${message().id}`}") expect(source).not.toContain("sessionTranscriptRowsForRender") - expect(source).not.toContain(' 0}>') - expect(source).not.toContain('anchorID={`queued-dock-${message.id}`}') + expect(source).not.toContain(" 0}>") + expect(source).not.toContain("anchorID={`queued-dock-${message.id}`}") expect(source).not.toContain("queuedMessageRenderIDs") expect(source).toMatch(/queued\s+sticky/) expect(source).toContain("bg: sendNowBackground()") @@ -267,7 +309,9 @@ describe("session route initialization", () => { expect(source).toContain('mode={currentWorkflowTask() ? "workflow-task"') expect(source).toContain('return `↑ Workflow ${keybind.print("session_parent")}${cycle}`') - expect(source).toContain('return `${workflowTask.workflowName} · Task ${workflowTask.taskIndex + 1}/${workflowTask.taskCount} · ${workflowTask.taskName}`') + expect(source).toContain( + "return `${workflowTask.workflowName} · Task ${workflowTask.taskIndex + 1}/${workflowTask.taskCount} · ${workflowTask.taskName}`", + ) expect(source).toContain('type: "workflows"') expect(source).toContain("selectedID: workflowTask.runID") expect(source).toContain("navigateWorkflowTask(1, dialog)") diff --git a/src/mendcode/packages/opencode/test/cli/tui/session-virtual-window-render.test.tsx b/src/mendcode/packages/opencode/test/cli/tui/session-virtual-window-render.test.tsx index 6bd4a292..c69f47e2 100644 --- a/src/mendcode/packages/opencode/test/cli/tui/session-virtual-window-render.test.tsx +++ b/src/mendcode/packages/opencode/test/cli/tui/session-virtual-window-render.test.tsx @@ -255,6 +255,37 @@ describe("session transcript headless rendering", () => { } }) + test("never exposes an empty transcript frame while submitting at the virtual tail", async () => { + let harness!: Harness + const app = await testRender(() => (harness = value)} />, { + width: 100, + height: 30, + }) + + try { + await settle(app) + harness.scroll.scrollTo(harness.scroll.scrollHeight) + harness.syncScrollTop() + await settle(app) + + const submitted = { id: "msg-300", role: "user" as const, height: 3 } + harness.append(submitted) + await app.renderOnce() + expect(harness.visibleTranscriptIDs()).not.toHaveLength(0) + + harness.setVirtualAnchorID(submitted.id) + harness.setFollow(false) + await app.renderOnce() + expect(harness.visibleTranscriptIDs()).not.toHaveLength(0) + expect(harness.mountedIDs()).toContain(submitted.id) + + await settle(app) + expect(app.captureCharFrame()).toContain(submitted.id) + } finally { + app.renderer.destroy() + } + }) + test("does not accumulate renderables while traversing a long transcript", async () => { let harness!: Harness const app = await testRender( @@ -337,10 +368,10 @@ describe("session transcript headless rendering", () => { height: 6, }), ) - const app = await testRender( - () => (harness = value)} />, - { width: 100, height: 30 }, - ) + const app = await testRender(() => (harness = value)} />, { + width: 100, + height: 30, + }) try { await settle(app) @@ -412,10 +443,10 @@ describe("session transcript headless rendering", () => { height: 6, }), ) - const app = await testRender( - () => (harness = value)} />, - { width: 100, height: 30 }, - ) + const app = await testRender(() => (harness = value)} />, { + width: 100, + height: 30, + }) try { await settle(app) diff --git a/src/mendcode/packages/opencode/test/cli/tui/subscription-usage.test.ts b/src/mendcode/packages/opencode/test/cli/tui/subscription-usage.test.ts new file mode 100644 index 00000000..d6b67bc5 --- /dev/null +++ b/src/mendcode/packages/opencode/test/cli/tui/subscription-usage.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test" +import { + parseCodexAppServerUsage, + parseCodexSubscriptionUsage, + subscriptionWindowLabel, +} from "@/cli/cmd/tui/routes/stats/subscription-usage" + +describe("stats subscription usage", () => { + test("reads the official Codex app-server rate-limit response", () => { + const usage = parseCodexAppServerUsage({ + rateLimits: { + planType: "pro", + primary: { usedPercent: 22, windowDurationMins: 300, resetsAt: 1_788_000_000 }, + secondary: null, + }, + rateLimitsByLimitId: null, + }) + expect(usage).toMatchObject({ + provider: "codex", + plan: "pro", + windows: [{ label: "5h", usedPercent: 22, windowMinutes: 300, resetsAt: 1_788_000_000_000 }], + }) + }) + + test("reads the latest valid Codex rate-limit event", () => { + const usage = parseCodexSubscriptionUsage( + [ + JSON.stringify({ payload: { type: "token_count", info: { rate_limits: null } } }), + JSON.stringify({ + timestamp: "2026-08-22T12:00:00.000Z", + payload: { + type: "token_count", + info: { + rate_limits: { + plan_type: "pro", + primary: { used_percent: 18, window_minutes: 300, resets_at: 1_788_000_000 }, + secondary: { used_percent: 89, window_minutes: 10_080, resets_at: 1_788_500_000 }, + }, + }, + }, + }), + ].join("\n"), + ) + + expect(usage).toEqual({ + provider: "codex", + plan: "pro", + updatedAt: Date.parse("2026-08-22T12:00:00.000Z"), + windows: [ + { label: "5h", usedPercent: 18, windowMinutes: 300, resetsAt: 1_788_000_000_000 }, + { label: "7d", usedPercent: 89, windowMinutes: 10_080, resetsAt: 1_788_500_000_000 }, + ], + }) + }) + + test("ignores malformed lines and clamps provider percentages", () => { + const usage = parseCodexSubscriptionUsage( + `{not-json}\n${JSON.stringify({ + payload: { + type: "token_count", + info: { rate_limits: { primary: { used_percent: 140, window_minutes: 60 } } }, + }, + })}`, + ) + expect(usage?.windows[0]).toMatchObject({ label: "1h", usedPercent: 100 }) + expect(subscriptionWindowLabel(45)).toBe("45m") + }) +}) diff --git a/src/mendcode/packages/opencode/test/mend/loop-service.test.ts b/src/mendcode/packages/opencode/test/mend/loop-service.test.ts index 15114be3..aa75f97b 100644 --- a/src/mendcode/packages/opencode/test/mend/loop-service.test.ts +++ b/src/mendcode/packages/opencode/test/mend/loop-service.test.ts @@ -6,8 +6,10 @@ import { tmpdir } from "../fixture/fixture" import { LoopWorkflow } from "../../src/session/loop" import { WithInstance } from "../../src/project/with-instance" import { resolveMendProjectRoot } from "../../src/mend/config/paths" +import { loopProjectScopeSql } from "../../src/mend/runtime/loop-project-scope" import { DEFAULT_LOOP_SERVICE_LIMIT, + loopDaemonCanDispatch, loopServiceArgsFromConfig, loopServiceDefinition, loopServicePlan, @@ -26,6 +28,21 @@ function runLoop(fx: Effect.Effect) { } describe("loop service plans", () => { + test("does not overlap daemon ticks", () => { + expect(loopDaemonCanDispatch(0)).toBe(true) + expect(loopDaemonCanDispatch(1)).toBe(false) + expect(loopDaemonCanDispatch(DEFAULT_LOOP_SERVICE_LIMIT)).toBe(false) + }) + + test("scopes local worktree loops through their root session", () => { + const scope = loopProjectScopeSql("?") + + expect(scope).toContain("s.id = w.root_session_id") + expect(scope).toContain("s.project_id = w.project_id") + expect(scope).toContain("s.directory = ?") + expect(scope.match(/\?/g)).toHaveLength(3) + }) + test("does not let a stale runtime-root override steal the project cwd", () => { expect(resolveMendProjectRoot("/work/project", "/runtime/mendcode", "/runtime/mendcode")).toBe("/work/project") expect(resolveMendProjectRoot("/runtime/mendcode", "/work/project", "/runtime/mendcode")).toBe("/work/project") @@ -68,11 +85,15 @@ describe("loop service plans", () => { process.env.OPENCODE_CHANNEL = channel delete process.env.MENDCODE_DB delete process.env.OPENCODE_DB - expect(path.basename(loopServicePlan({ projectRoot: "/tmp/repo", platform: "darwin" }).databasePath)).toBe(`mendcode-${channel}.db`) + expect(path.basename(loopServicePlan({ projectRoot: "/tmp/repo", platform: "darwin" }).databasePath)).toBe( + `mendcode-${channel}.db`, + ) process.env.MENDCODE_DB = "service.db" process.env.OPENCODE_DB = "service.db" - expect(path.basename(loopServicePlan({ projectRoot: "/tmp/repo", platform: "darwin" }).databasePath)).toBe("service.db") + expect(path.basename(loopServicePlan({ projectRoot: "/tmp/repo", platform: "darwin" }).databasePath)).toBe( + "service.db", + ) } finally { if (previousMendChannel === undefined) delete process.env.MENDCODE_CHANNEL else process.env.MENDCODE_CHANNEL = previousMendChannel @@ -175,6 +196,9 @@ describe("loop service plans", () => { expect(plan.serviceProgramArguments.slice(0, 2)).toEqual(["/bin/sh", "-c"]) expect(plan.serviceProgramArguments[2]).toContain("/usr/bin/sqlite3 -readonly") expect(plan.serviceProgramArguments[2]).toContain("/opt/mendcode loops daemon") + expect(plan.serviceProgramArguments[2]).toContain("s.id = w.root_session_id") + expect(plan.serviceProgramArguments[2]).toContain("s.directory = ") + expect(plan.serviceProgramArguments[2]).toContain("/tmp/repo") expect(plan.serviceProgramArguments[2]).toContain('if [ "$state" = "0" ]; then exit 0; fi') expect(plan.serviceProgramArguments[2]).not.toContain("launchctl bootout") expect(loopServicePlist(plan)).toContain("/bin/sh") @@ -300,21 +324,31 @@ describe("loop service plans", () => { try { const draft = await WithInstance.provide({ directory: projectRoot, - fn: () => runLoop(LoopWorkflow.Service.use((loop) => loop.createDraft({ - name: `T21 manual service smoke ${process.pid}`, - objective: "Exercise the installed loop service health path without provider execution.", - trigger: { mode: "interval", intervalMs: args.intervalMs }, - budgetMode: "unbounded-monitor", - }))), + fn: () => + runLoop( + LoopWorkflow.Service.use((loop) => + loop.createDraft({ + name: `T21 manual service smoke ${process.pid}`, + objective: "Exercise the installed loop service health path without provider execution.", + trigger: { mode: "interval", intervalMs: args.intervalMs }, + budgetMode: "unbounded-monitor", + }), + ), + ), }) manualLoopID = draft.id await WithInstance.provide({ directory: projectRoot, - fn: () => runLoop(LoopWorkflow.Service.use((loop) => loop.activate({ - id: draft.id, - reason: "T21 manual service smoke", - now: Date.now() - 1_000, - }))), + fn: () => + runLoop( + LoopWorkflow.Service.use((loop) => + loop.activate({ + id: draft.id, + reason: "T21 manual service smoke", + now: Date.now() - 1_000, + }), + ), + ), }) await loopServiceStart(args) diff --git a/src/mendcode/packages/opencode/test/mend/tui-presentation-renderers.test.ts b/src/mendcode/packages/opencode/test/mend/tui-presentation-renderers.test.ts index d02b50d8..ddb6add6 100644 --- a/src/mendcode/packages/opencode/test/mend/tui-presentation-renderers.test.ts +++ b/src/mendcode/packages/opencode/test/mend/tui-presentation-renderers.test.ts @@ -1,10 +1,35 @@ import { describe, expect, test } from "bun:test" import path from "path" -import { parseTimelineDiffRows, timelineDiffFileStatus, timelineDiffIsNonText } from "../../src/cli/cmd/tui/routes/session/renderers/diff-parse" -import { diffStatsFromPatch, formatDiffStats, patchFileTitle } from "../../src/cli/cmd/tui/routes/session/renderers/diff-label" +import { + parseTimelineDiffRows, + timelineDiffFileStatus, + timelineDiffIsNonText, +} from "../../src/cli/cmd/tui/routes/session/renderers/diff-parse" +import { + diffStatsFromPatch, + formatDiffStats, + patchFileTitle, +} from "../../src/cli/cmd/tui/routes/session/renderers/diff-label" import { compactMemoryGraphRows, compactMemoryGraphSnapshot } from "../../src/cli/cmd/tui/util/memory-graph" -import { compactionArcadeFrames, compactionStageStates, compactionSummaryPreview, rawReasoningDisplay, reasoningPreview, reasoningViewportMaxHeight, resolveTuiPresentation, shouldShowToolContinuation, toolContinuationActivity, unavailableReasoningLabel } from "../../src/mend/tui/presentation" -import { groupTimelineParts, isTimelineStackStart, timelineCollapseLabel, timelineNodeKeys } from "../../src/mend/tui/timeline/group" +import { + compactionArcadeFrames, + compactionStageStates, + compactionSummaryPreview, + rawReasoningDisplay, + reasoningPreview, + reasoningViewportHeight, + reasoningViewportMaxHeight, + resolveTuiPresentation, + shouldShowToolContinuation, + toolContinuationActivity, + unavailableReasoningLabel, +} from "../../src/mend/tui/presentation" +import { + groupTimelineParts, + isTimelineStackStart, + timelineCollapseLabel, + timelineNodeKeys, +} from "../../src/mend/tui/timeline/group" import { normalizeToolEvent, memoryToolPresentation, @@ -35,7 +60,12 @@ describe("mend tui presentation renderers", () => { allowScratchpad: true, arcade: "snake", }) - expect(resolveTuiPresentation({ profile: "mendcode", compaction: { style: "cockpit", showProgress: true, allowScratchpad: true, arcade: "snake" } }).compaction).toEqual({ + expect( + resolveTuiPresentation({ + profile: "mendcode", + compaction: { style: "cockpit", showProgress: true, allowScratchpad: true, arcade: "snake" }, + }).compaction, + ).toEqual({ style: "cockpit", showProgress: true, allowScratchpad: true, @@ -46,14 +76,15 @@ describe("mend tui presentation renderers", () => { }) test("compaction cockpit helpers skip empty headings and expose arcade modes", () => { - expect(compactionSummaryPreview("## Goal\n\nKeep implementing the approved plan.")).toBe("Keep implementing the approved plan.") + expect(compactionSummaryPreview("## Goal\n\nKeep implementing the approved plan.")).toBe( + "Keep implementing the approved plan.", + ) expect(compactionSummaryPreview("# Goal\n")).toBeUndefined() - expect(compactionStageStates({ hasSummary: true, tailStartID: "msg_tail", postPrompt: "next" }).map((stage) => stage.label)).toEqual([ - "Capture transcript", - "Write memory", - "Preserve tail", - "Continue", - ]) + expect( + compactionStageStates({ hasSummary: true, tailStartID: "msg_tail", postPrompt: "next" }).map( + (stage) => stage.label, + ), + ).toEqual(["Capture transcript", "Write memory", "Preserve tail", "Continue"]) expect(compactionArcadeFrames("snake")).toEqual([]) expect(compactionArcadeFrames("blocks").length).toBeGreaterThan(0) }) @@ -100,9 +131,27 @@ describe("mend tui presentation renderers", () => { test("compact memory graph rows keep relation endpoints readable without duplicates", () => { const snapshot = compactMemoryGraphSnapshot({ facts: [ - { id: "fact_a", text: "Use the complete durable memory text for the source endpoint.", scope: "project", categoryIDs: ["memory.policy"], materialized: true }, - { id: "fact_b", text: "Keep the related destination memory readable in session tools.", scope: "project", categoryIDs: ["memory.policy"], materialized: true }, - { id: "fact_c", text: "An isolated memory remains visible when no relation exists.", scope: "project", categoryIDs: ["memory.policy"], materialized: true }, + { + id: "fact_a", + text: "Use the complete durable memory text for the source endpoint.", + scope: "project", + categoryIDs: ["memory.policy"], + materialized: true, + }, + { + id: "fact_b", + text: "Keep the related destination memory readable in session tools.", + scope: "project", + categoryIDs: ["memory.policy"], + materialized: true, + }, + { + id: "fact_c", + text: "An isolated memory remains visible when no relation exists.", + scope: "project", + categoryIDs: ["memory.policy"], + materialized: true, + }, ], links: [{ from: "fact_a", to: "fact_b", kind: "supports" }], categories: [{ id: "memory.policy", label: "Memory policy", count: 3 }], @@ -119,7 +168,15 @@ describe("mend tui presentation renderers", () => { test("compact memory graph rows explain isolated memories", () => { const snapshot = compactMemoryGraphSnapshot({ - facts: [{ id: "fact_a", text: "This isolated memory should still be readable.", scope: "global", categoryIDs: ["agent.policy"], materialized: true }], + facts: [ + { + id: "fact_a", + text: "This isolated memory should still be readable.", + scope: "global", + categoryIDs: ["agent.policy"], + materialized: true, + }, + ], links: [], categories: [], }) @@ -186,7 +243,8 @@ describe("mend tui presentation renderers", () => { tool: "websearch", state: "completed", input: { query: "release notes" }, - output: "Found https://example.com/releases/2026-07?channel=stable#notes and https://docs.example.com/setup/install?os=mac.", + output: + "Found https://example.com/releases/2026-07?channel=stable#notes and https://docs.example.com/setup/install?os=mac.", }) expect(event.lines).toEqual([ @@ -196,10 +254,16 @@ describe("mend tui presentation renderers", () => { }) test("timeline line wrapping splits long URLs without dropping characters", () => { - const lines = wrapTimelineLine("", "https://example.com/docs/really/long/path/that/has/no/spaces?query=alpha-beta-gamma-delta#fragment", 36) + const lines = wrapTimelineLine( + "", + "https://example.com/docs/really/long/path/that/has/no/spaces?query=alpha-beta-gamma-delta#fragment", + 36, + ) expect(lines.length).toBeGreaterThan(1) - expect(lines.join("").replaceAll(" ", "")).toBe("https://example.com/docs/really/long/path/that/has/no/spaces?query=alpha-beta-gamma-delta#fragment") + expect(lines.join("").replaceAll(" ", "")).toBe( + "https://example.com/docs/really/long/path/that/has/no/spaces?query=alpha-beta-gamma-delta#fragment", + ) expect(lines.every((line) => Bun.stringWidth(line) <= 36)).toBe(true) }) @@ -210,7 +274,10 @@ describe("mend tui presentation renderers", () => { input: { pattern: "example" }, }) - const rendered = event.lines.length > 0 ? [`╭─ ${event.title}`, ...event.lines.map((line) => `│ ${line}`), `╰─ ${event.result ?? ""}`] : [`◈ ${event.title}`] + const rendered = + event.lines.length > 0 + ? [`╭─ ${event.title}`, ...event.lines.map((line) => `│ ${line}`), `╰─ ${event.result ?? ""}`] + : [`◈ ${event.title}`] expect(rendered).toEqual(['◈ Search "example"']) expect(rendered).not.toContain("╰─") @@ -246,7 +313,12 @@ describe("mend tui presentation renderers", () => { test("tool summaries include useful Grok-like details", () => { expect( - toolSummary("read", { filePath: "src/file.ts", offset: 2, limit: 45 }, undefined, "(Showing lines 2-46 of 66. Use offset=47 to continue.)").title, + toolSummary( + "read", + { filePath: "src/file.ts", offset: 2, limit: 45 }, + undefined, + "(Showing lines 2-46 of 66. Use offset=47 to continue.)", + ).title, ).toBe("Read src/file.ts (2-46 of 66)") expect( toolSummary( @@ -331,12 +403,9 @@ describe("mend tui presentation renderers", () => { questions: [ { header: "Format", - question: "Which editable and final report format should I use so simulations, screenshots, evidence, and appendices stay readable without breaking the block layout?", - options: [ - { label: "DOCX and PDF" }, - { label: "PDF only" }, - { label: "Markdown first" }, - ], + question: + "Which editable and final report format should I use so simulations, screenshots, evidence, and appendices stay readable without breaking the block layout?", + options: [{ label: "DOCX and PDF" }, { label: "PDF only" }, { label: "Markdown first" }], }, ], }, @@ -406,19 +475,23 @@ describe("mend tui presentation renderers", () => { }) test("loop tools stay on the rich card renderer in compact profiles", () => { - const nodes = groupTimelineParts("minimal", [ - { - id: "loop-tool", - type: "tool", - tool: "loop", - state: { - status: "completed", - input: { action: "show", workflowID: "loop_test" }, - metadata: { workflowID: "loop_test", state: "sleeping", phase: "waiting" }, - output: "loop_id: loop_test", + const nodes = groupTimelineParts( + "minimal", + [ + { + id: "loop-tool", + type: "tool", + tool: "loop", + state: { + status: "completed", + input: { action: "show", workflowID: "loop_test" }, + metadata: { workflowID: "loop_test", state: "sleeping", phase: "waiting" }, + output: "loop_id: loop_test", + }, }, - }, - ], { completed: true, showReasoningRows: true }) + ], + { completed: true, showReasoningRows: true }, + ) expect(nodes).toHaveLength(1) expect(nodes[0]).toMatchObject({ type: "tool", tool: "loop" }) @@ -450,15 +523,19 @@ describe("mend tui presentation renderers", () => { tool: "read", state: { status: "completed", input: { filePath: `file-${index + 1}.ts` }, output: "" }, })) - const nodes = groupTimelineParts("mendcode", [ - ...completedReads, - { - id: "web-1", - type: "tool", - tool: "websearch", - state: { status: "running", input: { query: "docs" } }, - }, - ], { completed: true, showReasoningRows: true }) + const nodes = groupTimelineParts( + "mendcode", + [ + ...completedReads, + { + id: "web-1", + type: "tool", + tool: "websearch", + state: { status: "running", input: { query: "docs" } }, + }, + ], + { completed: true, showReasoningRows: true }, + ) const labels = nodes.map(timelineNodeLabel) expect(labels).toEqual([ @@ -542,21 +619,29 @@ describe("mend tui presentation renderers", () => { tool: "read", state: { status: "completed", input: { filePath: `stack-${index + 1}.ts` }, output: "" }, })) - const nodes = groupTimelineParts("mendcode", [ - ...completedReads, - { - id: "todo-stack", - type: "tool", - tool: "todowrite", - state: { status: "completed", input: { todos: [{ content: "Ship the UI", status: "completed" }] }, output: "" }, - }, - { - id: "web-stack", - type: "tool", - tool: "websearch", - state: { status: "running", input: { query: "docs" } }, - }, - ], { completed: true, showReasoningRows: true }) + const nodes = groupTimelineParts( + "mendcode", + [ + ...completedReads, + { + id: "todo-stack", + type: "tool", + tool: "todowrite", + state: { + status: "completed", + input: { todos: [{ content: "Ship the UI", status: "completed" }] }, + output: "", + }, + }, + { + id: "web-stack", + type: "tool", + tool: "websearch", + state: { status: "running", input: { query: "docs" } }, + }, + ], + { completed: true, showReasoningRows: true }, + ) const labels = nodes.map(timelineNodeLabel) expect(labels[0]).toBe("◇ 8 tools more") @@ -569,33 +654,37 @@ describe("mend tui presentation renderers", () => { }) test("mendcode uses the live list for the latest grouped todo write", () => { - const nodes = groupTimelineParts("mendcode", [ - { - id: "todo-live", - type: "tool", - tool: "todowrite", - state: { - status: "completed", - input: { - todos: [ - { content: "Initial task", status: "in_progress" }, - { content: "Second task", status: "pending" }, - ], + const nodes = groupTimelineParts( + "mendcode", + [ + { + id: "todo-live", + type: "tool", + tool: "todowrite", + state: { + status: "completed", + input: { + todos: [ + { content: "Initial task", status: "in_progress" }, + { content: "Second task", status: "pending" }, + ], + }, + output: "", }, - output: "", }, - }, - ], { - completed: true, - latestTodoWritePartID: "todo-live", - currentTodos: [ - { content: "Task 1", status: "completed" }, - { content: "Task 2", status: "in_progress" }, - { content: "Task 3", status: "pending" }, - { content: "Task 4", status: "pending" }, - { content: "Task 5", status: "pending" }, ], - }) + { + completed: true, + latestTodoWritePartID: "todo-live", + currentTodos: [ + { content: "Task 1", status: "completed" }, + { content: "Task 2", status: "in_progress" }, + { content: "Task 3", status: "pending" }, + { content: "Task 4", status: "pending" }, + { content: "Task 5", status: "pending" }, + ], + }, + ) const row = nodes.find((node) => isTimelineRowWithTitle(node, "Todos")) expect(row).toMatchObject({ @@ -838,6 +927,12 @@ describe("mend tui presentation renderers", () => { expect(reasoningViewportMaxHeight(40, { min: 6, max: 10, ratio: 0.5 })).toBe(10) }) + test("sizes the Full reasoning viewport to content before applying its cap", () => { + expect(reasoningViewportHeight("short reasoning", 14)).toBe(1) + expect(reasoningViewportHeight("one\ntwo\nthree", 14)).toBe(3) + expect(reasoningViewportHeight(Array.from({ length: 20 }, () => "step").join("\n"), 14)).toBe(14) + }) + test("shows tool continuation only while a non-terminal tool is actually active", () => { expect(shouldShowToolContinuation({ finish: "tool-calls", terminal: false, activeTool: true })).toBe(true) expect(shouldShowToolContinuation({ finish: "tool-calls", terminal: false, activeTool: false })).toBe(false) diff --git a/src/mendcode/packages/opencode/test/session/agent-command.test.ts b/src/mendcode/packages/opencode/test/session/agent-command.test.ts index 35ecb227..91ed2d7d 100644 --- a/src/mendcode/packages/opencode/test/session/agent-command.test.ts +++ b/src/mendcode/packages/opencode/test/session/agent-command.test.ts @@ -12,51 +12,69 @@ import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" function run(fx: Effect.Effect) { - return Effect.runPromise(fx.pipe(Effect.provide(Layer.mergeAll(SessionNs.defaultLayer, BackgroundSession.defaultLayer, AgentCommand.defaultLayer)))) + return Effect.runPromise( + fx.pipe( + Effect.provide(Layer.mergeAll(SessionNs.defaultLayer, BackgroundSession.defaultLayer, AgentCommand.defaultLayer)), + ), + ) } const svc = { createSession(input?: SessionNs.CreateInput) { - return run(Effect.gen(function* () { - const session = yield* SessionNs.Service - return yield* session.create(input) - })) + return run( + Effect.gen(function* () { + const session = yield* SessionNs.Service + return yield* session.create(input) + }), + ) }, create(input: AgentCommand.CreateInput) { - return run(Effect.gen(function* () { - const command = yield* AgentCommand.Service - return yield* command.create(input) - })) + return run( + Effect.gen(function* () { + const command = yield* AgentCommand.Service + return yield* command.create(input) + }), + ) }, get(id: AgentCommandID) { - return run(Effect.gen(function* () { - const command = yield* AgentCommand.Service - return yield* command.get(id) - })) + return run( + Effect.gen(function* () { + const command = yield* AgentCommand.Service + return yield* command.get(id) + }), + ) }, list(input?: AgentCommand.ListInput) { - return run(Effect.gen(function* () { - const command = yield* AgentCommand.Service - return yield* command.list(input) - })) + return run( + Effect.gen(function* () { + const command = yield* AgentCommand.Service + return yield* command.list(input) + }), + ) }, update(input: AgentCommand.UpdateInput) { - return run(Effect.gen(function* () { - const command = yield* AgentCommand.Service - return yield* command.update(input) - })) + return run( + Effect.gen(function* () { + const command = yield* AgentCommand.Service + return yield* command.update(input) + }), + ) }, registerBackground(input: BackgroundSession.RegisterInput) { - return run(Effect.gen(function* () { - const background = yield* BackgroundSession.Service - return yield* background.register(input) - })) + return run( + Effect.gen(function* () { + const background = yield* BackgroundSession.Service + return yield* background.register(input) + }), + ) }, acquireWriter(input: BackgroundSession.WriterInput) { - return run(Effect.gen(function* () { - const background = yield* BackgroundSession.Service - return yield* background.acquireWriter(input) - })) + return run( + Effect.gen(function* () { + const background = yield* BackgroundSession.Service + return yield* background.acquireWriter(input) + }), + ) }, } @@ -100,6 +118,68 @@ describe("Agent Command inbox", () => { }) }) + test("queues same-workspace peer text without granting tool permissions", async () => { + await using tmp = await tmpdir({ git: true }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const source = await svc.createSession({ title: "coordinator" }) + const target = await svc.createSession({ title: "worker" }) + + const command = await svc.create({ + sourceSessionID: source.id, + targetSessionID: target.id, + type: "peer_message", + payload: { text: " inspect the latest test failure " }, + }) + + expect(command).toMatchObject({ + sourceSessionID: source.id, + targetSessionID: target.id, + type: "peer_message", + state: "pending", + permissions: [], + policy: { + decision: "same_workspace", + permissions: [], + }, + payload: { + text: "inspect the latest test failure", + sourceTitle: "coordinator", + }, + }) + + await expect( + svc.create({ + sourceSessionID: source.id, + targetSessionID: target.id, + type: "peer_message", + payload: { text: " " }, + }), + ).rejects.toThrow("Peer message text cannot be empty.") + }, + }) + }) + + test("rejects peer text addressed to its own source session", async () => { + await using tmp = await tmpdir({ git: true }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const source = await svc.createSession({ title: "coordinator" }) + + await expect( + svc.create({ + sourceSessionID: source.id, + targetSessionID: source.id, + type: "peer_message", + payload: { text: "loop back" }, + }), + ).rejects.toThrow("Peer messages require a different target session.") + }, + }) + }) + test("advances valid command states and rejects terminal rewrites", async () => { await using tmp = await tmpdir({ git: true }) await WithInstance.provide({ @@ -116,7 +196,12 @@ describe("Agent Command inbox", () => { const accepted = await svc.update({ id: command.id, targetSessionID: target.id, state: "accepted" }) expect(accepted.state).toBe("accepted") - const completed = await svc.update({ id: command.id, targetSessionID: target.id, state: "completed", result: "done" }) + const completed = await svc.update({ + id: command.id, + targetSessionID: target.id, + state: "completed", + result: "done", + }) expect(completed).toMatchObject({ state: "completed", result: "done" }) await expect(svc.update({ id: command.id, targetSessionID: target.id, state: "running" })).rejects.toThrow( @@ -154,7 +239,13 @@ describe("Agent Command inbox", () => { state: "completed", result: "Updated target Agent View tags.", }) - expect(Database.use((db) => db.select().from(AgentViewMetadataTable).where(eq(AgentViewMetadataTable.session_id, target.id)).get()?.data)).toEqual({ + expect( + Database.use( + (db) => + db.select().from(AgentViewMetadataTable).where(eq(AgentViewMetadataTable.session_id, target.id)).get() + ?.data, + ), + ).toEqual({ title: "Worker Alpha", tags: ["frontend", "urgent"], }) @@ -188,10 +279,12 @@ describe("Agent Command inbox", () => { expect(rename.permissions).toEqual(["agent_view.metadata.patch"]) expect(rename.policy).toMatchObject({ decision: "same_workspace", permissions: ["agent_view.metadata.patch"] }) - const storedSession = await run(Effect.gen(function* () { - const session = yield* SessionNs.Service - return yield* session.get(target.id) - })) + const storedSession = await run( + Effect.gen(function* () { + const session = yield* SessionNs.Service + return yield* session.get(target.id) + }), + ) expect(storedSession.title).toBe("Original transcript title") }, }) @@ -255,8 +348,18 @@ describe("Agent Command inbox", () => { type: "request_summary", payload: { instructions: "status one" }, }) - await svc.create({ sourceSessionID: source.id, targetSessionID: target.id, type: "request_summary", payload: { instructions: "status two" } }) - await svc.create({ sourceSessionID: source.id, targetSessionID: target.id, type: "request_summary", payload: { instructions: "status three" } }) + await svc.create({ + sourceSessionID: source.id, + targetSessionID: target.id, + type: "request_summary", + payload: { instructions: "status two" }, + }) + await svc.create({ + sourceSessionID: source.id, + targetSessionID: target.id, + type: "request_summary", + payload: { instructions: "status three" }, + }) const overflow = await svc.create({ sourceSessionID: source.id, targetSessionID: target.id, @@ -283,7 +386,8 @@ describe("Agent Command inbox", () => { const id = AgentCommandID.ascending() const now = Date.now() Database.use((db) => - db.insert(AgentCommandTable) + db + .insert(AgentCommandTable) .values({ id, source_session_id: source.id, @@ -336,7 +440,11 @@ describe("Agent Command inbox", () => { }) expect(await svc.list({ targetSessionID: target.id })).toEqual([ - expect.objectContaining({ id: command.id, state: "expired", error: "Command expired before the target accepted it." }), + expect.objectContaining({ + id: command.id, + state: "expired", + error: "Command expired before the target accepted it.", + }), ]) await expect(svc.update({ id: command.id, targetSessionID: target.id, state: "accepted" })).rejects.toThrow( "Invalid agent command state transition", @@ -452,7 +560,8 @@ describe("Agent Command inbox", () => { const id = AgentCommandID.ascending() const now = Date.now() Database.use((db) => - db.insert(AgentCommandTable) + db + .insert(AgentCommandTable) .values({ id, source_session_id: source.id, @@ -468,7 +577,9 @@ describe("Agent Command inbox", () => { }) .run(), ) - Database.use((db) => db.delete(BackgroundSessionTable).where(eq(BackgroundSessionTable.session_id, target.id)).run()) + Database.use((db) => + db.delete(BackgroundSessionTable).where(eq(BackgroundSessionTable.session_id, target.id)).run(), + ) expect(await svc.list({ targetSessionID: target.id })).toEqual([ expect.objectContaining({ @@ -498,7 +609,8 @@ describe("Agent Command inbox", () => { const id = AgentCommandID.ascending() const now = Date.now() Database.use((db) => - db.insert(AgentCommandTable) + db + .insert(AgentCommandTable) .values({ id, source_session_id: source.id, @@ -540,7 +652,8 @@ describe("Agent Command inbox", () => { const id = AgentCommandID.ascending() const now = Date.now() Database.use((db) => - db.insert(AgentCommandTable) + db + .insert(AgentCommandTable) .values({ id, source_session_id: source.id, @@ -589,14 +702,37 @@ describe("Agent Command inbox", () => { }) test("exposes a conservative command policy matrix", () => { - expect(AgentCommandPolicy.matrix()).toEqual(expect.arrayContaining([ - expect.objectContaining({ type: "request_summary", decision: "safe_auto", permissions: ["session.summary.read"] }), - expect.objectContaining({ type: "rename", decision: "same_workspace", permissions: ["agent_view.metadata.patch"] }), - expect.objectContaining({ type: "tag", decision: "same_workspace", permissions: ["agent_view.metadata.patch"] }), - expect.objectContaining({ type: "pause_after_turn", decision: "approval_required", permissions: ["session.control.pause_after_turn"] }), - expect.objectContaining({ type: "stop", decision: "approval_required", permissions: ["session.control.stop"] }), - expect.objectContaining({ type: "send_message", decision: "approval_required", permissions: ["session.message.send"] }), - ])) + expect(AgentCommandPolicy.matrix()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "request_summary", + decision: "safe_auto", + permissions: ["session.summary.read"], + }), + expect.objectContaining({ + type: "rename", + decision: "same_workspace", + permissions: ["agent_view.metadata.patch"], + }), + expect.objectContaining({ + type: "tag", + decision: "same_workspace", + permissions: ["agent_view.metadata.patch"], + }), + expect.objectContaining({ + type: "pause_after_turn", + decision: "approval_required", + permissions: ["session.control.pause_after_turn"], + }), + expect.objectContaining({ type: "stop", decision: "approval_required", permissions: ["session.control.stop"] }), + expect.objectContaining({ + type: "send_message", + decision: "approval_required", + permissions: ["session.message.send"], + }), + expect.objectContaining({ type: "peer_message", decision: "same_workspace", permissions: [] }), + ]), + ) }) test("scopes commands to the current project and rejects cross-project targets", async () => { diff --git a/src/mendcode/packages/opencode/test/session/loop-workflow-adapter.test.ts b/src/mendcode/packages/opencode/test/session/loop-workflow-adapter.test.ts index f6ea5dd1..8aaf0625 100644 --- a/src/mendcode/packages/opencode/test/session/loop-workflow-adapter.test.ts +++ b/src/mendcode/packages/opencode/test/session/loop-workflow-adapter.test.ts @@ -116,6 +116,7 @@ function promptLayer() { loop: () => Effect.succeed(message), shell: () => Effect.succeed(message), command: () => Effect.succeed(message), + wakePeerDelivery: () => Effect.void, resolvePromptParts: () => Effect.succeed([]), }), ) diff --git a/src/mendcode/packages/opencode/test/session/loop.test.ts b/src/mendcode/packages/opencode/test/session/loop.test.ts index 2e457e0a..b9ab7349 100644 --- a/src/mendcode/packages/opencode/test/session/loop.test.ts +++ b/src/mendcode/packages/opencode/test/session/loop.test.ts @@ -139,6 +139,7 @@ function runRunner( loop: () => Effect.succeed(promptMessage()), shell: () => Effect.succeed(promptMessage()), command: () => Effect.succeed(promptMessage()), + wakePeerDelivery: () => Effect.void, resolvePromptParts: () => Effect.succeed([]), }), ) @@ -990,39 +991,45 @@ describe("loop workflow service", () => { directory: tmp.path, fn: async () => { const draft = await runWithWorktree( - LoopWorkflowService.use((loop) => loop.createDraft({ - name: "Audit candidate worktree", - objective: "Audit the exact workspace that proposed completion.", - budgetMode: "max-goal", - completionCriteria: ["candidate workspace is reused"], - policy: { maxTurns: 3 }, - evaluation: { mode: "independent", confirmation: "next-run" }, - workspace: { mode: "per-run-worktree" }, - })), + LoopWorkflowService.use((loop) => + loop.createDraft({ + name: "Audit candidate worktree", + objective: "Audit the exact workspace that proposed completion.", + budgetMode: "max-goal", + completionCriteria: ["candidate workspace is reused"], + policy: { maxTurns: 3 }, + evaluation: { mode: "independent", confirmation: "next-run" }, + workspace: { mode: "per-run-worktree" }, + }), + ), ) await runWithWorktree(LoopWorkflowService.use((loop) => loop.activate({ id: draft.id }))) const workRun = await runWithWorktree( LoopWorkflowService.use((loop) => loop.startRun({ id: draft.id, trigger: "manual" })), ) const now = Date.now() - await runWithWorktree(LoopWorkflowService.use((loop) => loop.completeRun({ - id: draft.id, - runID: workRun.id, - now, - goalStatus: "complete", - checkpoint: { status: "complete", summary: "Candidate ready", evidence: ["workspace prepared"] }, - completion: { - candidate: { - status: "candidate", - generation: 1, - sourceID: workRun.id, - auditAttempts: 0, - candidateFingerprint: "candidate-fingerprint", - createdAt: now, - updatedAt: now, - }, - }, - }))) + await runWithWorktree( + LoopWorkflowService.use((loop) => + loop.completeRun({ + id: draft.id, + runID: workRun.id, + now, + goalStatus: "complete", + checkpoint: { status: "complete", summary: "Candidate ready", evidence: ["workspace prepared"] }, + completion: { + candidate: { + status: "candidate", + generation: 1, + sourceID: workRun.id, + auditAttempts: 0, + candidateFingerprint: "candidate-fingerprint", + createdAt: now, + updatedAt: now, + }, + }, + }), + ), + ) const auditRun = await runWithWorktree( LoopWorkflowService.use((loop) => loop.startRun({ id: draft.id, trigger: "manual" })), @@ -1474,7 +1481,12 @@ describe("loop workflow service", () => { phase: "waiting", nextWakeup: expect.any(Number), }) - expect(recovered.runs[0]).toMatchObject({ id: stuck.id, state: "failed", phase: "stale", failureClass: "transient" }) + expect(recovered.runs[0]).toMatchObject({ + id: stuck.id, + state: "failed", + phase: "stale", + failureClass: "transient", + }) expect(recovered.workflow.failureClass).toBe("transient") expect( Database.use((db) => @@ -2311,7 +2323,10 @@ describe("loop workflow service", () => { "- baseline prediction recorded", "next_action: retry next iteration", ].join("\n") - await runRunner(LoopRunner.Service.use((runner) => runner.runOne({ id: draft.id, execute: true })), checkpoint) + await runRunner( + LoopRunner.Service.use((runner) => runner.runOne({ id: draft.id, execute: true })), + checkpoint, + ) const incomplete = await runRunner( LoopRunner.Service.use((runner) => runner.runOne({ id: draft.id, execute: true })), @@ -2324,7 +2339,9 @@ describe("loop workflow service", () => { expect(snapshot.workflow.state).toBe("sleeping") expect(snapshot.workflow.phase).toBe("retry_scheduled") expect(snapshot.workflow.failureClass).toBe("transient") - expect(snapshot.runs.find((run) => run.checkpoint?.summary === "First iteration checkpoint is durable.")?.checkpoint).toMatchObject({ + expect( + snapshot.runs.find((run) => run.checkpoint?.summary === "First iteration checkpoint is durable.")?.checkpoint, + ).toMatchObject({ status: "continue", summary: "First iteration checkpoint is durable.", }) @@ -2332,7 +2349,11 @@ describe("loop workflow service", () => { expect(retriedRun?.retry?.backoffMs).toBe(30_000) Database.use((db) => - db.update(LoopWorkflowTable).set({ next_wakeup: Date.now() - 1 }).where(eq(LoopWorkflowTable.id, draft.id)).run(), + db + .update(LoopWorkflowTable) + .set({ next_wakeup: Date.now() - 1 }) + .where(eq(LoopWorkflowTable.id, draft.id)) + .run(), ) const missingCheckpoint = await runRunner( @@ -2346,6 +2367,44 @@ describe("loop workflow service", () => { }) }) + test("incomplete worker output after a tool call pauses instead of replaying the iteration", async () => { + await using tmp = await tmpdir({ git: true }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const draft = await svc.createDraft({ + name: "Interrupted mutating worker", + objective: "Do not replay a possibly mutating tool call.", + trigger: { mode: "self-paced" }, + policy: { maxTurns: 3 }, + }) + await svc.activate(draft.id) + const interrupted = promptMessageWithInspection("partial output after tool execution") + if (interrupted.info.role !== "assistant") throw new Error("Expected assistant fixture") + const tool = interrupted.parts.find((part): part is MessageV2.ToolPart => part.type === "tool") + if (!tool) throw new Error("Expected tool fixture") + tool.tool = "bash" + tool.state.input = { command: "git merge --no-commit origin/main" } + interrupted.info.finish = undefined + + const result = await runRunner( + LoopRunner.Service.use((runner) => runner.runOne({ id: draft.id, execute: true })), + interrupted, + ) + + expect(result.value.state).toBe("needs_input") + expect(result.value.summary).toContain("Automatic retry paused") + const snapshot = await svc.snapshot(draft.id) + expect(snapshot.workflow.state).toBe("needs_input") + expect(snapshot.workflow.phase).toBe("needs_input") + expect(snapshot.workflow.failureClass).toBe("user_input") + expect(snapshot.workflow.metrics.failures).toBe(1) + expect(snapshot.workflow.metrics.turns).toBe(0) + expect(snapshot.runs[0]?.retry).toBeUndefined() + }, + }) + }) + test("retryable failures become terminal after the retry budget is exhausted", async () => { await using tmp = await tmpdir({ git: true }) await WithInstance.provide({ @@ -2697,6 +2756,7 @@ describe("loop workflow service", () => { loop: () => Effect.succeed(promptMessage()), shell: () => Effect.succeed(promptMessage()), command: () => Effect.succeed(promptMessage()), + wakePeerDelivery: () => Effect.void, resolvePromptParts: () => Effect.succeed([]), }), ) @@ -3610,7 +3670,8 @@ describe("loop workflow service", () => { expect(snapshot.runs[0]?.completion?.candidateFingerprint).toBeTruthy() Database.use((db) => - db.update(LoopWorkflowTable) + db + .update(LoopWorkflowTable) .set({ state: "sleeping", phase: "retry_scheduled", next_wakeup: Date.now() - 1 }) .where(eq(LoopWorkflowTable.id, draft.id)) .run(), diff --git a/src/mendcode/packages/opencode/test/session/prompt.test.ts b/src/mendcode/packages/opencode/test/session/prompt.test.ts index dabfad25..ac41faa5 100644 --- a/src/mendcode/packages/opencode/test/session/prompt.test.ts +++ b/src/mendcode/packages/opencode/test/session/prompt.test.ts @@ -23,6 +23,7 @@ import { Question } from "../../src/question" import { PlanReview } from "../../src/plan-review" import { Todo } from "../../src/session/todo" import { Session } from "@/session/session" +import { AgentCommand } from "../../src/session/agent-command" import { MessageTable, PartTable, SessionMessageTable, SessionStatusTable } from "../../src/session/session.sql" import { BackgroundTaskEventTable, BackgroundTaskTable } from "../../src/session/background-task.sql" import { LLM } from "../../src/session/llm" @@ -109,7 +110,10 @@ test("finds the first persisted user turn after the latest completed assistant", }, parts: [], } satisfies MessageV2.WithParts - const queued = { info: { ...userInfo(), time: { created: 4 }, queued: true }, parts: [] } satisfies MessageV2.WithParts + const queued = { + info: { ...userInfo(), time: { created: 4 }, queued: true }, + parts: [], + } satisfies MessageV2.WithParts const secondQueued = { info: { ...userInfo(), time: { created: 5 }, queued: true }, parts: [], @@ -120,7 +124,10 @@ test("finds the first persisted user turn after the latest completed assistant", test("recovers an unanswered queued turn even after later assistant and compaction steps", () => { const activeUser = { info: { ...userInfo(), time: { created: 1 } }, parts: [] } satisfies MessageV2.WithParts - const queued = { info: { ...userInfo(), time: { created: 2 }, queued: true }, parts: [] } satisfies MessageV2.WithParts + const queued = { + info: { ...userInfo(), time: { created: 2 }, queued: true }, + parts: [], + } satisfies MessageV2.WithParts const activeAssistant = { info: { ...assistantInfo({ @@ -169,7 +176,10 @@ test("does not recover while an assistant is still incomplete", () => { }, parts: [], } satisfies MessageV2.WithParts - const queued = { info: { ...userInfo(), time: { created: 3 }, queued: true }, parts: [] } satisfies MessageV2.WithParts + const queued = { + info: { ...userInfo(), time: { created: 3 }, queued: true }, + parts: [], + } satisfies MessageV2.WithParts expect(findRecoverableQueuedPrompt([user, assistant, queued])).toBeUndefined() }) @@ -360,6 +370,7 @@ function makeHttp() { const deps = Layer.mergeAll( bus, Session.defaultLayer, + AgentCommand.defaultLayer, Snapshot.defaultLayer, LLM.defaultLayer, Env.defaultLayer, @@ -560,9 +571,7 @@ runStateIt.instance("allows an explicit cancel-turn to stop a non-interruptible yield* state.setInterruptible(sessionID, false) expect(yield* state.cancelTurn(sessionID, targetMessageID)).toBe("not_interruptible") - expect( - yield* state.cancelTurn(sessionID, targetMessageID, { ignoreInterruptible: true }), - ).toBe("cancelled") + expect(yield* state.cancelTurn(sessionID, targetMessageID, { ignoreInterruptible: true })).toBe("cancelled") expect((yield* Fiber.join(active)).info.id).toBe(fallback.info.id) }), ) @@ -1493,7 +1502,17 @@ it.live("wakes the creator when a workflow reaches a terminal state", () => description: "Owner wake integration test", objective: "Notify the creator", phases: [{ id: "final", ordinal: 1, name: "Finish", barrier: { kind: "all" }, taskIDs: ["final"] }], - tasks: [{ id: "final", phaseID: "final", name: "Finish", kind: "synthesize", prompt: "Finish", dependsOn: [], output: { kind: "text" } }], + tasks: [ + { + id: "final", + phaseID: "final", + name: "Finish", + kind: "synthesize", + prompt: "Finish", + dependsOn: [], + output: { kind: "text" }, + }, + ], finalTaskID: "final", completionCriteria: ["Finished"], requiredGates: [], @@ -1507,9 +1526,12 @@ it.live("wakes the creator when a workflow reaches a terminal state", () => expect(yield* llm.calls).toBe(2) const messages = yield* sessions.messages({ sessionID: parent.id }) expect( - messages.some((message) => - message.info.role === "user" - && message.parts.some((part) => part.type === "text" && part.synthetic === true && part.text.includes(started.run.id)), + messages.some( + (message) => + message.info.role === "user" && + message.parts.some( + (part) => part.type === "text" && part.synthetic === true && part.text.includes(started.run.id), + ), ), ).toBe(true) expect(yield* workflows.pendingNotifications(parent.id)).toEqual([]) @@ -1584,6 +1606,70 @@ it.live("does not wake a parent after manual cancellation", () => ), ) +it.live("does not wake a parent when cancel-turn arrives after the target completed", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const tasks = yield* BackgroundTask.Service + const parent = yield* sessions.create({ title: "Terminal cancel owner wake parent" }) + yield* llm.text("parent ready") + yield* prompt.prompt({ + sessionID: parent.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "start" }], + }) + const target = (yield* sessions.messages({ sessionID: parent.id })).find( + (message) => + message.info.role === "user" && message.parts.some((part) => part.type === "text" && part.text === "start"), + ) + if (!target) throw new Error("target user message missing") + + const child = yield* sessions.create({ parentID: parent.id, title: "Inspect cache", agent: "general" }) + const run = yield* tasks.start({ + taskID: child.id, + parentSessionID: parent.id, + title: "Inspect cache", + agent: "general", + startRunning: true, + }) + + expect(yield* prompt.cancelTurn({ sessionID: parent.id, targetMessageID: target.info.id })).toBe( + "already_terminal", + ) + yield* tasks.finish({ + taskID: child.id, + generation: run.generation, + state: "completed", + background: true, + result: { summary: "Cache is healthy." }, + }) + yield* Effect.sleep(500) + + const messages = yield* sessions.messages({ sessionID: parent.id }) + expect( + messages.some( + (message) => + message.info.role === "user" && + message.parts.some( + (part) => + part.type === "text" && part.synthetic === true && part.metadata?.kind === "background_task_owner_wake", + ), + ), + ).toBe(false) + expect(yield* llm.calls).toBe(1) + expect(yield* tasks.pendingNotifications(parent.id)).toEqual([]) + }), + { + git: true, + config: (url: string) => ({ + ...providerCfg(url), + }), + }, + ), +) + it.live("loop exits immediately when last assistant has stop finish", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { @@ -2219,13 +2305,96 @@ it.live("promptAsync never replays a delivered prompt whose assistant is still i expect(duplicate.info.id).toBe(message.id) expect(yield* llm.calls).toBe(0) const messages = yield* sessions.messages({ sessionID: chat.id }) - expect(messages.filter((item) => item.info.role === "assistant" && item.info.parentID === message.id)).toHaveLength(1) + expect( + messages.filter((item) => item.info.role === "assistant" && item.info.parentID === message.id), + ).toHaveLength(1) expect(messages.some((item) => item.info.id === assistant.id)).toBe(true) }), { git: true, config: providerCfg }, ), ) +it.live("delivers an accepted peer message once with visible provenance", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const commands = yield* AgentCommand.Service + const source = yield* sessions.create({ title: "Peer sender" }) + const target = yield* sessions.create({ + title: "Peer receiver", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* llm.text("peer response") + + const command = yield* commands.create({ + sourceSessionID: source.id, + targetSessionID: target.id, + type: "peer_message", + payload: { text: "hello from the sender" }, + }) + expect(command.state).toBe("pending") + yield* commands.update({ id: command.id, targetSessionID: target.id, state: "accepted" }) + yield* prompt.wakePeerDelivery(target.id) + yield* prompt.wakePeerDelivery(target.id) + + const delivered = yield* Effect.gen(function* () { + while (true) { + const messages = yield* sessions.messages({ sessionID: target.id, view: "full" }) + const received = messages.find((message) => + message.parts.some( + (part) => + part.type === "text" && + (part.metadata as Record | undefined)?.deliveryID === command.id, + ), + ) + const state = yield* commands.get(command.id) + const assistantCompleted = messages.some( + (message) => + message.info.role === "assistant" && + message.info.parentID === received?.info.id && + message.info.time.completed !== undefined, + ) + if (received && state.state === "completed" && assistantCompleted) return true + yield* Effect.sleep("1 millis") + } + }).pipe( + Effect.timeout("2 seconds"), + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ) + expect(delivered).toBe(true) + + const messages = yield* sessions.messages({ sessionID: target.id, view: "full" }) + const received = messages.find((message) => + message.parts.some( + (part) => + part.type === "text" && (part.metadata as Record | undefined)?.deliveryID === command.id, + ), + ) + const receivedPart = received?.parts.find( + (part) => + part.type === "text" && (part.metadata as Record | undefined)?.deliveryID === command.id, + ) + expect(receivedPart && receivedPart.type === "text" ? receivedPart.text : undefined).toBe("hello from the sender") + expect( + receivedPart && receivedPart.type === "text" + ? (receivedPart.metadata as Record | undefined)?.sourceSessionID + : undefined, + ).toBe(source.id) + expect(yield* commands.get(command.id).pipe(Effect.map((info) => info.state))).toBe("completed") + expect(yield* llm.calls).toBe(1) + }), + { + git: true, + config: (url) => ({ + ...providerCfg(url), + agent: { build: { model: "test/test-model" } }, + }), + }, + ), +) + it.live("startup recovery finalizes stale foreground work without replaying it", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { @@ -2279,11 +2448,7 @@ it.live("startup recovery finalizes stale foreground work without replaying it", const rows = Database.use((db) => ({ message: db.select().from(MessageTable).where(Database.eq(MessageTable.id, assistant.id)).get(), part: db.select().from(PartTable).where(Database.eq(PartTable.id, tool.id)).get(), - status: db - .select() - .from(SessionStatusTable) - .where(Database.eq(SessionStatusTable.session_id, chat.id)) - .get(), + status: db.select().from(SessionStatusTable).where(Database.eq(SessionStatusTable.session_id, chat.id)).get(), })) expect(rows.message?.data.role).toBe("assistant") if (rows.message?.data.role !== "assistant") return @@ -2418,14 +2583,14 @@ it.live("cancel-turn aborts an active compaction and finalizes its summary", () const compacting = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) yield* llm.wait(2) - expect(yield* prompt.cancelTurn({ sessionID: chat.id, targetMessageID: compactionUser.info.id })).toBe("cancelled") + expect(yield* prompt.cancelTurn({ sessionID: chat.id, targetMessageID: compactionUser.info.id })).toBe( + "cancelled", + ) gate.resolve() yield* Fiber.join(compacting).pipe(Effect.timeout("1 second")) const messages = yield* sessions.messages({ sessionID: chat.id }) - const summary = messages.findLast( - (message) => message.info.role === "assistant" && message.info.summary === true, - ) + const summary = messages.findLast((message) => message.info.role === "assistant" && message.info.summary === true) expect(summary?.info.role).toBe("assistant") if (summary?.info.role === "assistant") { expect(summary.info.error?.name).toBe("MessageAbortedError") @@ -3384,7 +3549,9 @@ it.live("loop returns to idle when automatic memory extraction times out", () => sessionID: session.id, agent: "build", noReply: true, - parts: [{ type: "text", text: "Remember that stalled memory extraction must never keep the session busy." }], + parts: [ + { type: "text", text: "Remember that stalled memory extraction must never keep the session busy." }, + ], }) yield* llm.text("understood") @@ -4564,9 +4731,7 @@ unix( expect(exit.value.info.error?.name).toBe("MessageAbortedError") expect(exit.value.info.time.completed).toBeNumber() expect( - exit.value.parts.some( - (part) => part.type === "tool" && ["pending", "running"].includes(part.state.status), - ), + exit.value.parts.some((part) => part.type === "tool" && ["pending", "running"].includes(part.state.status)), ).toBe(false) expect((yield* status.get(chat.id)).type).toBe("idle") }), @@ -4608,17 +4773,27 @@ unix( yield* Effect.gen(function* () { while (true) { const messages = yield* sessions.messages({ sessionID: chat.id }) - if (messages.some((message) => message.parts.some((part) => part.type === "tool" && part.state.status === "running"))) return + if ( + messages.some((message) => + message.parts.some((part) => part.type === "tool" && part.state.status === "running"), + ) + ) + return yield* Effect.sleep("1 millis") } }).pipe(Effect.timeout("1 second")) expect(yield* prompt.cancelTurn({ sessionID: chat.id, targetMessageID })).toBe("cancelled") const exit = yield* Fiber.await(run).pipe(Effect.timeout("1 second")) expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit) && exit.value.info.role === "assistant") expect(exit.value.info.error?.name).toBe("MessageAbortedError") + if (Exit.isSuccess(exit) && exit.value.info.role === "assistant") + expect(exit.value.info.error?.name).toBe("MessageAbortedError") expect((yield* status.get(chat.id)).type).toBe("idle") const messages = yield* sessions.messages({ sessionID: chat.id }) - expect(messages.flatMap((message) => message.parts).some((part) => part.type === "tool" && ["pending", "running"].includes(part.state.status))).toBe(false) + expect( + messages + .flatMap((message) => message.parts) + .some((part) => part.type === "tool" && ["pending", "running"].includes(part.state.status)), + ).toBe(false) }), { git: true, config: providerCfg }, ), diff --git a/src/mendcode/packages/opencode/test/session/workflow-task-executor.test.ts b/src/mendcode/packages/opencode/test/session/workflow-task-executor.test.ts index db37b7ec..b5a7b5de 100644 --- a/src/mendcode/packages/opencode/test/session/workflow-task-executor.test.ts +++ b/src/mendcode/packages/opencode/test/session/workflow-task-executor.test.ts @@ -76,12 +76,17 @@ function runExecutor(input: { loop: () => Effect.succeed(promptMessage(input.promptText)), shell: () => Effect.succeed(promptMessage(input.promptText)), command: () => Effect.succeed(promptMessage(input.promptText)), + wakePeerDelivery: () => Effect.void, resolvePromptParts: () => Effect.succeed([]), }), ) return Effect.runPromise( WorkflowTaskExecutor.Service.use((executor) => - executor.execute({ task: input.task, sessionID: "ses_executor_root" as never, workflowModel: input.workflowModel }), + executor.execute({ + task: input.task, + sessionID: "ses_executor_root" as never, + workflowModel: input.workflowModel, + }), ).pipe(Effect.provide(WorkflowTaskExecutor.layer.pipe(Layer.provide(promptLayer)))), ) } diff --git a/src/mendcode/packages/opencode/test/tool/peers.test.ts b/src/mendcode/packages/opencode/test/tool/peers.test.ts new file mode 100644 index 00000000..b3ff576b --- /dev/null +++ b/src/mendcode/packages/opencode/test/tool/peers.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test" +import { peerDescriptors } from "../../src/tool/peers" +import type { Session } from "../../src/session/session" +import type { SessionStatus } from "../../src/session/status" + +function session(id: string, updated: number, title = id) { + return { + id, + title, + time: { created: updated - 1_000, updated }, + workspaceID: "workspace-a", + } as Session.Info +} + +describe("peer directory", () => { + test("returns bounded same-project descriptors without the current session", () => { + const sessions = [session("current", 10), session("older", 20, "Older worker"), session("newer", 30, "New worker")] + const statuses = new Map([ + ["older", { type: "busy", message: "Inspecting files" }], + ["newer", { type: "idle" }], + ]) + + expect(peerDescriptors({ sessions, statuses, currentSessionID: "current", limit: 10 })).toEqual([ + { + sessionID: "newer", + title: "New worker", + state: "idle", + workspaceID: "workspace-a", + updatedAt: 30, + }, + { + sessionID: "older", + title: "Older worker", + state: "busy", + activity: "Inspecting files", + workspaceID: "workspace-a", + updatedAt: 20, + }, + ]) + }) + + test("caps the requested limit and activity text", () => { + const longActivity = "x".repeat(300) + const descriptors = peerDescriptors({ + sessions: Array.from({ length: 40 }, (_, index) => session(`peer-${index}`, index)), + statuses: new Map([["peer-39", { type: "busy", message: longActivity } as SessionStatus.Info]]), + currentSessionID: "none", + limit: 999, + }) + + expect(descriptors).toHaveLength(32) + expect(descriptors.find((peer) => peer.sessionID === "peer-39")?.activity).toHaveLength(120) + }) +}) diff --git a/src/mendcode/packages/opencode/test/tool/registry.test.ts b/src/mendcode/packages/opencode/test/tool/registry.test.ts index 5d631244..76c9d007 100644 --- a/src/mendcode/packages/opencode/test/tool/registry.test.ts +++ b/src/mendcode/packages/opencode/test/tool/registry.test.ts @@ -15,6 +15,7 @@ import { Todo } from "@/session/todo" import { Skill } from "@/skill" import { Agent } from "@/agent/agent" import { Session } from "@/session/session" +import { AgentCommand } from "@/session/agent-command" import { SessionStatus } from "@/session/status" import { BackgroundTask } from "@/session/background-task" import { Provider } from "@/provider/provider" @@ -100,7 +101,7 @@ const registryLayer = ( Layer.provide([LoopRunner.defaultLayer, Todo.defaultLayer, WorkflowService.defaultLayer]), Layer.provide(Skill.defaultLayer), Layer.provide(Agent.defaultLayer), - Layer.provide(Session.defaultLayer), + Layer.provide([Session.defaultLayer, AgentCommand.defaultLayer]), Layer.provide([SessionStatus.defaultLayer, BackgroundTask.defaultLayer]), Layer.provide(providerLayer), Layer.provide(LSP.defaultLayer), @@ -148,10 +149,14 @@ const apiKeyIt = testEffect(Layer.mergeAll(registryLayer(apiKeyInfo, imageConfig const apiKeyDefaultIt = testEffect(Layer.mergeAll(registryLayer(apiKeyInfo, {}, imageProviderLayer), node)) const disabledImageIt = testEffect( Layer.mergeAll( - registryLayer(undefined, { - ...imageConfig, - image_generation: { enabled: false, model: "openai/test-image-model" }, - }, imageProviderLayer), + registryLayer( + undefined, + { + ...imageConfig, + image_generation: { enabled: false, model: "openai/test-image-model" }, + }, + imageProviderLayer, + ), node, ), ) @@ -186,6 +191,8 @@ describe("tool.registry", () => { expect(ids).toContain("memory") expect(ids).toContain("memory_graph") expect(ids).toContain("review") + expect(ids).toContain("tell") + expect(ids).toContain("peers") expect(ids).toContain("task_status") expect(ids).toContain("write") expect(ids).not.toContain("image_gen") diff --git a/src/mendcode/packages/sdk/js/src/v2/gen/sdk.gen.ts b/src/mendcode/packages/sdk/js/src/v2/gen/sdk.gen.ts index 871e2da9..3e3c2ae0 100644 --- a/src/mendcode/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/src/mendcode/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -3986,7 +3986,8 @@ export class Session2 extends HeyApiClient { limit?: number before?: string after?: string - view?: "full" | "tui" | "tui-all" + view?: "full" | "tui" | "tui-all" | "history" + unit?: "message" | "turn" partsLimit?: string }, options?: Options, @@ -4003,6 +4004,7 @@ export class Session2 extends HeyApiClient { { in: "query", key: "before" }, { in: "query", key: "after" }, { in: "query", key: "view" }, + { in: "query", key: "unit" }, { in: "query", key: "partsLimit" }, ], }, @@ -4126,7 +4128,7 @@ export class Session2 extends HeyApiClient { messageID: string directory?: string workspace?: string - view?: "full" | "tui" | "tui-all" + view?: "full" | "tui" | "tui-all" | "history" partsLimit?: string partsAfter?: string }, @@ -5820,6 +5822,20 @@ export class Workflow extends HeyApiClient { * Concrete criteria that prove the workflow finished successfully. Must not be empty. */ completionCriteria: Array + completion?: { + confirmation?: "same-run" | "next-run" + maxAuditAttempts?: number + validationChecks?: Array<{ + id: string + command: string + timeoutMs?: number + }> + criteria?: Array<{ + id: string + description: string + ownerTaskIDs?: Array + }> + } /** * Approval gates required before execution. Use [] for immediate execution. */ @@ -6097,6 +6113,20 @@ export class Workflow extends HeyApiClient { * Concrete criteria that prove the workflow finished successfully. Must not be empty. */ completionCriteria: Array + completion?: { + confirmation?: "same-run" | "next-run" + maxAuditAttempts?: number + validationChecks?: Array<{ + id: string + command: string + timeoutMs?: number + }> + criteria?: Array<{ + id: string + description: string + ownerTaskIDs?: Array + }> + } /** * Approval gates required before execution. Use [] for immediate execution. */ @@ -6386,6 +6416,20 @@ export class Workflow extends HeyApiClient { * Concrete criteria that prove the workflow finished successfully. Must not be empty. */ completionCriteria: Array + completion?: { + confirmation?: "same-run" | "next-run" + maxAuditAttempts?: number + validationChecks?: Array<{ + id: string + command: string + timeoutMs?: number + }> + criteria?: Array<{ + id: string + description: string + ownerTaskIDs?: Array + }> + } /** * Approval gates required before execution. Use [] for immediate execution. */ diff --git a/src/mendcode/packages/sdk/js/src/v2/gen/types.gen.ts b/src/mendcode/packages/sdk/js/src/v2/gen/types.gen.ts index 17ec1df2..c36cf223 100644 --- a/src/mendcode/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/src/mendcode/packages/sdk/js/src/v2/gen/types.gen.ts @@ -351,6 +351,26 @@ export type AgentCommand = text: string } } + | { + id: string + sourceSessionID: string + targetSessionID: string + state: "pending" | "accepted" | "running" | "completed" | "rejected" | "failed" | "expired" + permissions: Array + policy: AgentCommandPolicy + error?: string + result?: string + expiresAt?: number + time: { + created: number + updated: number + } + type: "peer_message" + payload: { + text: string + sourceTitle?: string + } + } export type SessionStatus = | { @@ -361,6 +381,11 @@ export type SessionStatus = attempt: number message: string next: number + recovery?: { + kind: "stale-session" + reason: string + tool?: string + } } | { type: "busy" @@ -654,6 +679,7 @@ export type LoopWorkflow = { agent?: string evaluation?: { mode?: "legacy" | "deterministic" | "independent" + confirmation?: "same-run" | "next-run" evaluatorAgent?: string requireIndependentForCompletion?: boolean allowWorkerSelfComplete?: boolean @@ -824,6 +850,41 @@ export type LoopRun = { time: number } }> + completion?: { + status: "candidate" | "auditing" | "passed" | "rejected" | "blocked" + generation: number + sourceID: string + auditAttempts: number + candidateFingerprint?: string + summary?: string + failedCriteria?: Array + receipt?: { + generation: number + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + criteria: Array<{ + id: string + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + evidence: Array<{ + id: string + kind: "command-output" | "artifact" | "state" | "observation" | "human-approval" + summary: string + source?: string + }> + }> + fingerprintBefore?: string + fingerprintAfter?: string + recommendedNextAction?: string + createdAt: number + } + auditLease?: { + holder: string + expiresAt: number + } + createdAt: number + updatedAt: number + } retry?: { attempt: number backoffMs?: number @@ -2295,7 +2356,7 @@ export type AgentViewMetadataPatch = { } export type AgentCommandPolicyMatrixItem = { - type: "request_summary" | "rename" | "tag" | "pause_after_turn" | "stop" | "send_message" + type: "request_summary" | "rename" | "tag" | "pause_after_turn" | "stop" | "send_message" | "peer_message" decision: "safe_auto" | "same_workspace" | "approval_required" | "denied" permissions: Array reason: string @@ -2350,6 +2411,14 @@ export type AgentCommandCreate = } expiresAt?: number } + | { + sourceSessionID: string + type: "peer_message" + payload: { + text: string + } + expiresAt?: number + } export type AgentCommandUpdate = { state?: "pending" | "accepted" | "running" | "completed" | "rejected" | "failed" | "expired" @@ -2560,6 +2629,7 @@ export type LoopWorkflow1 = { agent?: string evaluation?: { mode?: "legacy" | "deterministic" | "independent" + confirmation?: "same-run" | "next-run" evaluatorAgent?: string requireIndependentForCompletion?: boolean allowWorkerSelfComplete?: boolean @@ -2730,6 +2800,41 @@ export type LoopRun1 = { time: number } }> + completion?: { + status: "candidate" | "auditing" | "passed" | "rejected" | "blocked" + generation: number + sourceID: string + auditAttempts: number + candidateFingerprint?: string + summary?: string + failedCriteria?: Array + receipt?: { + generation: number + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + criteria: Array<{ + id: string + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + evidence: Array<{ + id: string + kind: "command-output" | "artifact" | "state" | "observation" | "human-approval" + summary: string + source?: string + }> + }> + fingerprintBefore?: string + fingerprintAfter?: string + recommendedNextAction?: string + createdAt: number + } + auditLease?: { + holder: string + expiresAt: number + } + createdAt: number + updatedAt: number + } retry?: { attempt: number backoffMs?: number @@ -4678,6 +4783,11 @@ export type GlobalDiagnosticsMemoryResponses = { external: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" arrayBuffers: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" uptimeSeconds: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + memoryGuardrail: { + status: "ok" | "warning" | "critical" + warnBytes: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + criticalBytes: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } sharedServer?: { runtimeID: string stateOwner: boolean @@ -7236,6 +7346,7 @@ export type SessionMessagesData = { before?: string after?: string view?: "full" | "tui" | "tui-all" | "history" + unit?: "message" | "turn" partsLimit?: string } url: "/session/{sessionID}/message" @@ -8956,6 +9067,20 @@ export type WorkflowPreviewData = { * Concrete criteria that prove the workflow finished successfully. Must not be empty. */ completionCriteria: Array + completion?: { + confirmation?: "same-run" | "next-run" + maxAuditAttempts?: number + validationChecks?: Array<{ + id: string + command: string + timeoutMs?: number + }> + criteria?: Array<{ + id: string + description: string + ownerTaskIDs?: Array + }> + } /** * Approval gates required before execution. Use [] for immediate execution. */ @@ -9237,6 +9362,20 @@ export type WorkflowSaveData = { * Concrete criteria that prove the workflow finished successfully. Must not be empty. */ completionCriteria: Array + completion?: { + confirmation?: "same-run" | "next-run" + maxAuditAttempts?: number + validationChecks?: Array<{ + id: string + command: string + timeoutMs?: number + }> + criteria?: Array<{ + id: string + description: string + ownerTaskIDs?: Array + }> + } /** * Approval gates required before execution. Use [] for immediate execution. */ @@ -9512,6 +9651,20 @@ export type WorkflowSaveResponses = { * Concrete criteria that prove the workflow finished successfully. Must not be empty. */ completionCriteria: Array + completion?: { + confirmation?: "same-run" | "next-run" + maxAuditAttempts?: number + validationChecks?: Array<{ + id: string + command: string + timeoutMs?: number + }> + criteria?: Array<{ + id: string + description: string + ownerTaskIDs?: Array + }> + } /** * Approval gates required before execution. Use [] for immediate execution. */ @@ -9768,6 +9921,20 @@ export type WorkflowStartData = { * Concrete criteria that prove the workflow finished successfully. Must not be empty. */ completionCriteria: Array + completion?: { + confirmation?: "same-run" | "next-run" + maxAuditAttempts?: number + validationChecks?: Array<{ + id: string + command: string + timeoutMs?: number + }> + criteria?: Array<{ + id: string + description: string + ownerTaskIDs?: Array + }> + } /** * Approval gates required before execution. Use [] for immediate execution. */ @@ -10061,6 +10228,20 @@ export type WorkflowStartResponses = { * Concrete criteria that prove the workflow finished successfully. Must not be empty. */ completionCriteria: Array + completion?: { + confirmation?: "same-run" | "next-run" + maxAuditAttempts?: number + validationChecks?: Array<{ + id: string + command: string + timeoutMs?: number + }> + criteria?: Array<{ + id: string + description: string + ownerTaskIDs?: Array + }> + } /** * Approval gates required before execution. Use [] for immediate execution. */ @@ -10125,6 +10306,41 @@ export type WorkflowStartResponses = { } permissionMode?: "report-only" | "normal" | "custom" sessionPermissionMode?: "approval" | "smart" | "full_access" + completion?: { + status: "candidate" | "auditing" | "passed" | "rejected" | "blocked" + generation: number + sourceID: string + auditAttempts: number + candidateFingerprint?: string + summary?: string + failedCriteria?: Array + receipt?: { + generation: number + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + criteria: Array<{ + id: string + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + evidence: Array<{ + id: string + kind: "command-output" | "artifact" | "state" | "observation" | "human-approval" + summary: string + source?: string + }> + }> + fingerprintBefore?: string + fingerprintAfter?: string + recommendedNextAction?: string + createdAt: number + } + auditLease?: { + holder: string + expiresAt: number + } + createdAt: number + updatedAt: number + } state: | "planning" | "awaiting_approval" @@ -10628,6 +10844,20 @@ export type WorkflowListResponses = { * Concrete criteria that prove the workflow finished successfully. Must not be empty. */ completionCriteria: Array + completion?: { + confirmation?: "same-run" | "next-run" + maxAuditAttempts?: number + validationChecks?: Array<{ + id: string + command: string + timeoutMs?: number + }> + criteria?: Array<{ + id: string + description: string + ownerTaskIDs?: Array + }> + } /** * Approval gates required before execution. Use [] for immediate execution. */ @@ -10692,6 +10922,41 @@ export type WorkflowListResponses = { } permissionMode?: "report-only" | "normal" | "custom" sessionPermissionMode?: "approval" | "smart" | "full_access" + completion?: { + status: "candidate" | "auditing" | "passed" | "rejected" | "blocked" + generation: number + sourceID: string + auditAttempts: number + candidateFingerprint?: string + summary?: string + failedCriteria?: Array + receipt?: { + generation: number + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + criteria: Array<{ + id: string + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + evidence: Array<{ + id: string + kind: "command-output" | "artifact" | "state" | "observation" | "human-approval" + summary: string + source?: string + }> + }> + fingerprintBefore?: string + fingerprintAfter?: string + recommendedNextAction?: string + createdAt: number + } + auditLease?: { + holder: string + expiresAt: number + } + createdAt: number + updatedAt: number + } state: | "planning" | "awaiting_approval" @@ -11234,6 +11499,20 @@ export type WorkflowShowResponses = { * Concrete criteria that prove the workflow finished successfully. Must not be empty. */ completionCriteria: Array + completion?: { + confirmation?: "same-run" | "next-run" + maxAuditAttempts?: number + validationChecks?: Array<{ + id: string + command: string + timeoutMs?: number + }> + criteria?: Array<{ + id: string + description: string + ownerTaskIDs?: Array + }> + } /** * Approval gates required before execution. Use [] for immediate execution. */ @@ -11298,6 +11577,41 @@ export type WorkflowShowResponses = { } permissionMode?: "report-only" | "normal" | "custom" sessionPermissionMode?: "approval" | "smart" | "full_access" + completion?: { + status: "candidate" | "auditing" | "passed" | "rejected" | "blocked" + generation: number + sourceID: string + auditAttempts: number + candidateFingerprint?: string + summary?: string + failedCriteria?: Array + receipt?: { + generation: number + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + criteria: Array<{ + id: string + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + evidence: Array<{ + id: string + kind: "command-output" | "artifact" | "state" | "observation" | "human-approval" + summary: string + source?: string + }> + }> + fingerprintBefore?: string + fingerprintAfter?: string + recommendedNextAction?: string + createdAt: number + } + auditLease?: { + holder: string + expiresAt: number + } + createdAt: number + updatedAt: number + } state: | "planning" | "awaiting_approval" @@ -11902,6 +12216,20 @@ export type WorkflowPauseResponses = { * Concrete criteria that prove the workflow finished successfully. Must not be empty. */ completionCriteria: Array + completion?: { + confirmation?: "same-run" | "next-run" + maxAuditAttempts?: number + validationChecks?: Array<{ + id: string + command: string + timeoutMs?: number + }> + criteria?: Array<{ + id: string + description: string + ownerTaskIDs?: Array + }> + } /** * Approval gates required before execution. Use [] for immediate execution. */ @@ -11966,6 +12294,41 @@ export type WorkflowPauseResponses = { } permissionMode?: "report-only" | "normal" | "custom" sessionPermissionMode?: "approval" | "smart" | "full_access" + completion?: { + status: "candidate" | "auditing" | "passed" | "rejected" | "blocked" + generation: number + sourceID: string + auditAttempts: number + candidateFingerprint?: string + summary?: string + failedCriteria?: Array + receipt?: { + generation: number + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + criteria: Array<{ + id: string + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + evidence: Array<{ + id: string + kind: "command-output" | "artifact" | "state" | "observation" | "human-approval" + summary: string + source?: string + }> + }> + fingerprintBefore?: string + fingerprintAfter?: string + recommendedNextAction?: string + createdAt: number + } + auditLease?: { + holder: string + expiresAt: number + } + createdAt: number + updatedAt: number + } state: | "planning" | "awaiting_approval" @@ -12476,6 +12839,20 @@ export type WorkflowResumeResponses = { * Concrete criteria that prove the workflow finished successfully. Must not be empty. */ completionCriteria: Array + completion?: { + confirmation?: "same-run" | "next-run" + maxAuditAttempts?: number + validationChecks?: Array<{ + id: string + command: string + timeoutMs?: number + }> + criteria?: Array<{ + id: string + description: string + ownerTaskIDs?: Array + }> + } /** * Approval gates required before execution. Use [] for immediate execution. */ @@ -12540,6 +12917,41 @@ export type WorkflowResumeResponses = { } permissionMode?: "report-only" | "normal" | "custom" sessionPermissionMode?: "approval" | "smart" | "full_access" + completion?: { + status: "candidate" | "auditing" | "passed" | "rejected" | "blocked" + generation: number + sourceID: string + auditAttempts: number + candidateFingerprint?: string + summary?: string + failedCriteria?: Array + receipt?: { + generation: number + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + criteria: Array<{ + id: string + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + evidence: Array<{ + id: string + kind: "command-output" | "artifact" | "state" | "observation" | "human-approval" + summary: string + source?: string + }> + }> + fingerprintBefore?: string + fingerprintAfter?: string + recommendedNextAction?: string + createdAt: number + } + auditLease?: { + holder: string + expiresAt: number + } + createdAt: number + updatedAt: number + } state: | "planning" | "awaiting_approval" @@ -13050,6 +13462,20 @@ export type WorkflowStopResponses = { * Concrete criteria that prove the workflow finished successfully. Must not be empty. */ completionCriteria: Array + completion?: { + confirmation?: "same-run" | "next-run" + maxAuditAttempts?: number + validationChecks?: Array<{ + id: string + command: string + timeoutMs?: number + }> + criteria?: Array<{ + id: string + description: string + ownerTaskIDs?: Array + }> + } /** * Approval gates required before execution. Use [] for immediate execution. */ @@ -13114,6 +13540,41 @@ export type WorkflowStopResponses = { } permissionMode?: "report-only" | "normal" | "custom" sessionPermissionMode?: "approval" | "smart" | "full_access" + completion?: { + status: "candidate" | "auditing" | "passed" | "rejected" | "blocked" + generation: number + sourceID: string + auditAttempts: number + candidateFingerprint?: string + summary?: string + failedCriteria?: Array + receipt?: { + generation: number + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + criteria: Array<{ + id: string + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + evidence: Array<{ + id: string + kind: "command-output" | "artifact" | "state" | "observation" | "human-approval" + summary: string + source?: string + }> + }> + fingerprintBefore?: string + fingerprintAfter?: string + recommendedNextAction?: string + createdAt: number + } + auditLease?: { + holder: string + expiresAt: number + } + createdAt: number + updatedAt: number + } state: | "planning" | "awaiting_approval" @@ -13626,6 +14087,20 @@ export type WorkflowPermissionModeResponses = { * Concrete criteria that prove the workflow finished successfully. Must not be empty. */ completionCriteria: Array + completion?: { + confirmation?: "same-run" | "next-run" + maxAuditAttempts?: number + validationChecks?: Array<{ + id: string + command: string + timeoutMs?: number + }> + criteria?: Array<{ + id: string + description: string + ownerTaskIDs?: Array + }> + } /** * Approval gates required before execution. Use [] for immediate execution. */ @@ -13690,6 +14165,41 @@ export type WorkflowPermissionModeResponses = { } permissionMode?: "report-only" | "normal" | "custom" sessionPermissionMode?: "approval" | "smart" | "full_access" + completion?: { + status: "candidate" | "auditing" | "passed" | "rejected" | "blocked" + generation: number + sourceID: string + auditAttempts: number + candidateFingerprint?: string + summary?: string + failedCriteria?: Array + receipt?: { + generation: number + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + criteria: Array<{ + id: string + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + evidence: Array<{ + id: string + kind: "command-output" | "artifact" | "state" | "observation" | "human-approval" + summary: string + source?: string + }> + }> + fingerprintBefore?: string + fingerprintAfter?: string + recommendedNextAction?: string + createdAt: number + } + auditLease?: { + holder: string + expiresAt: number + } + createdAt: number + updatedAt: number + } state: | "planning" | "awaiting_approval" @@ -14201,6 +14711,20 @@ export type WorkflowRetryTaskResponses = { * Concrete criteria that prove the workflow finished successfully. Must not be empty. */ completionCriteria: Array + completion?: { + confirmation?: "same-run" | "next-run" + maxAuditAttempts?: number + validationChecks?: Array<{ + id: string + command: string + timeoutMs?: number + }> + criteria?: Array<{ + id: string + description: string + ownerTaskIDs?: Array + }> + } /** * Approval gates required before execution. Use [] for immediate execution. */ @@ -14265,6 +14789,41 @@ export type WorkflowRetryTaskResponses = { } permissionMode?: "report-only" | "normal" | "custom" sessionPermissionMode?: "approval" | "smart" | "full_access" + completion?: { + status: "candidate" | "auditing" | "passed" | "rejected" | "blocked" + generation: number + sourceID: string + auditAttempts: number + candidateFingerprint?: string + summary?: string + failedCriteria?: Array + receipt?: { + generation: number + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + criteria: Array<{ + id: string + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + evidence: Array<{ + id: string + kind: "command-output" | "artifact" | "state" | "observation" | "human-approval" + summary: string + source?: string + }> + }> + fingerprintBefore?: string + fingerprintAfter?: string + recommendedNextAction?: string + createdAt: number + } + auditLease?: { + holder: string + expiresAt: number + } + createdAt: number + updatedAt: number + } state: | "planning" | "awaiting_approval" @@ -14776,6 +15335,20 @@ export type WorkflowRetryPhaseResponses = { * Concrete criteria that prove the workflow finished successfully. Must not be empty. */ completionCriteria: Array + completion?: { + confirmation?: "same-run" | "next-run" + maxAuditAttempts?: number + validationChecks?: Array<{ + id: string + command: string + timeoutMs?: number + }> + criteria?: Array<{ + id: string + description: string + ownerTaskIDs?: Array + }> + } /** * Approval gates required before execution. Use [] for immediate execution. */ @@ -14840,6 +15413,41 @@ export type WorkflowRetryPhaseResponses = { } permissionMode?: "report-only" | "normal" | "custom" sessionPermissionMode?: "approval" | "smart" | "full_access" + completion?: { + status: "candidate" | "auditing" | "passed" | "rejected" | "blocked" + generation: number + sourceID: string + auditAttempts: number + candidateFingerprint?: string + summary?: string + failedCriteria?: Array + receipt?: { + generation: number + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + criteria: Array<{ + id: string + status: "pass" | "fail" | "uncertain" | "blocked" | "needs_human" + summary: string + evidence: Array<{ + id: string + kind: "command-output" | "artifact" | "state" | "observation" | "human-approval" + summary: string + source?: string + }> + }> + fingerprintBefore?: string + fingerprintAfter?: string + recommendedNextAction?: string + createdAt: number + } + auditLease?: { + holder: string + expiresAt: number + } + createdAt: number + updatedAt: number + } state: | "planning" | "awaiting_approval"