restore: bring back all custom UI enhancements from checkpoint

Restored from commit 52be710 (checkpoint before qwen oauth + todo roller):

Enhanced UI Features:
- SMART FIX button with AI code analysis
- APEX (Autonomous Programming EXecution) mode
- SHIELD (Auto-approval) mode
- MULTIX MODE multi-task pipeline interface
- Live streaming token counter
- Thinking indicator with bouncing dots animation

Components restored:
- packages/ui/src/components/chat/multi-task-chat.tsx
- packages/ui/src/components/instance/instance-shell2.tsx
- packages/ui/src/components/settings/OllamaCloudSettings.tsx
- packages/ui/src/components/settings/QwenCodeSettings.tsx
- packages/ui/src/stores/solo-store.ts
- packages/ui/src/stores/task-actions.ts
- packages/ui/src/stores/session-events.ts (autonomous mode)
- packages/server/src/integrations/ollama-cloud.ts
- packages/server/src/server/routes/ollama.ts
- packages/server/src/server/routes/qwen.ts

This ensures all custom features are preserved in source control.
This commit is contained in:
Gemini AI
2025-12-23 13:18:37 +04:00
Unverified
parent 157449a9ad
commit c4ac079660
47 changed files with 4550 additions and 527 deletions

View File

@@ -5,7 +5,7 @@ import { getLogger } from "../lib/logger"
const log = getLogger("api")
const DEFAULT_INSTANCE_DATA: InstanceData = { messageHistory: [], agentModelSelections: {} }
const DEFAULT_INSTANCE_DATA: InstanceData = { messageHistory: [], agentModelSelections: {}, sessionTasks: {} }
const [instanceDataMap, setInstanceDataMap] = createSignal<Map<string, InstanceData>>(new Map())
const loadPromises = new Map<string, Promise<void>>()
@@ -17,6 +17,7 @@ function cloneInstanceData(data?: InstanceData | null): InstanceData {
...source,
messageHistory: Array.isArray(source.messageHistory) ? [...source.messageHistory] : [],
agentModelSelections: { ...(source.agentModelSelections ?? {}) },
sessionTasks: { ...(source.sessionTasks ?? {}) },
}
}

View File

@@ -1,4 +1,5 @@
import { createSignal } from "solid-js"
import { createSignal, createMemo, batch } from "solid-js"
import { resetSteps } from "./solo-store"
import type { Instance, LogEntry } from "../types/instance"
import type { LspStatus, Permission } from "@opencode-ai/sdk"
import { sdkManager } from "../lib/sdk-manager"
@@ -34,6 +35,11 @@ const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boole
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, Permission[]>>(new Map())
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
const permissionSessionCounts = new Map<string, Map<string, number>>()
function syncHasInstancesFlag() {
const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready")
setHasInstances(readyExists)
}
interface DisconnectedInstanceInfo {
id: string
folder: string
@@ -68,7 +74,6 @@ function upsertWorkspace(descriptor: WorkspaceDescriptor) {
updateInstance(descriptor.id, mapped)
} else {
addInstance(mapped)
setHasInstances(true)
}
if (descriptor.status === "ready") {
@@ -135,9 +140,6 @@ void (async function initializeWorkspaces() {
try {
const workspaces = await serverApi.fetchWorkspaces()
workspaces.forEach((workspace) => upsertWorkspace(workspace))
if (workspaces.length === 0) {
setHasInstances(false)
}
} catch (error) {
log.error("Failed to load workspaces", error)
}
@@ -159,9 +161,6 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) {
case "workspace.stopped":
releaseInstanceResources(event.workspaceId)
removeInstance(event.workspaceId)
if (instances().size === 0) {
setHasInstances(false)
}
break
case "workspace.log":
handleWorkspaceLog(event.entry)
@@ -249,6 +248,8 @@ function addInstance(instance: Instance) {
})
ensureLogContainer(instance.id)
ensureLogStreamingState(instance.id)
resetSteps(instance.id) // Initialize SOLO steps
syncHasInstancesFlag()
}
function updateInstance(id: string, updates: Partial<Instance>) {
@@ -260,6 +261,7 @@ function updateInstance(id: string, updates: Partial<Instance>) {
}
return next
})
syncHasInstancesFlag()
}
function removeInstance(id: string) {
@@ -301,6 +303,7 @@ function removeInstance(id: string) {
clearCacheForInstance(id)
messageStoreBus.unregisterInstance(id)
clearInstanceDraftPrompts(id)
syncHasInstancesFlag()
}
async function createInstance(folder: string, _binaryPath?: string): Promise<string> {
@@ -328,9 +331,6 @@ async function stopInstance(id: string) {
}
removeInstance(id)
if (instances().size === 0) {
setHasInstances(false)
}
}
async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefined> {
@@ -590,9 +590,6 @@ async function acknowledgeDisconnectedInstance(): Promise<void> {
log.error("Failed to stop disconnected instance", error)
} finally {
setDisconnectedInstance(null)
if (instances().size === 0) {
setHasInstances(false)
}
}
}

View File

@@ -99,6 +99,8 @@ function createEmptyUsageState(): SessionUsageState {
totalInputTokens: 0,
totalOutputTokens: 0,
totalReasoningTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
totalCost: 0,
actualUsageTokens: 0,
latestMessageId: undefined,
@@ -154,6 +156,8 @@ function removeUsageEntry(state: SessionUsageState, messageId: string | undefine
state.totalInputTokens -= existing.inputTokens
state.totalOutputTokens -= existing.outputTokens
state.totalReasoningTokens -= existing.reasoningTokens
state.totalCacheReadTokens -= existing.cacheReadTokens
state.totalCacheWriteTokens -= existing.cacheWriteTokens
state.totalCost -= existing.cost
delete state.entries[messageId]
if (state.latestMessageId === messageId) {
@@ -520,7 +524,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
draft.partIds = [...draft.partIds, partId]
}
const existing = draft.parts[partId]
const nextRevision = existing ? existing.revision + 1 : cloned.version ?? 0
const nextRevision = existing ? existing.revision + 1 : 0
draft.parts[partId] = {
id: partId,
data: cloned,

View File

@@ -28,6 +28,8 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void {
let totalInputTokens = usage?.totalInputTokens ?? 0
let totalOutputTokens = usage?.totalOutputTokens ?? 0
let totalReasoningTokens = usage?.totalReasoningTokens ?? 0
let totalCacheReadTokens = usage?.totalCacheReadTokens ?? 0
let totalCacheWriteTokens = usage?.totalCacheWriteTokens ?? 0
let totalCost = usage?.totalCost ?? 0
let actualUsageTokens = usage?.actualUsageTokens ?? 0
@@ -44,6 +46,8 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void {
totalInputTokens = previousInfo.inputTokens
totalOutputTokens = previousInfo.outputTokens
totalReasoningTokens = previousInfo.reasoningTokens
totalCacheReadTokens = previousInfo.cacheReadTokens
totalCacheWriteTokens = previousInfo.cacheWriteTokens
totalCost = previousInfo.cost
actualUsageTokens = previousInfo.actualUsageTokens
}
@@ -129,6 +133,8 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void {
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
reasoningTokens: totalReasoningTokens,
cacheReadTokens: totalCacheReadTokens,
cacheWriteTokens: totalCacheWriteTokens,
actualUsageTokens,
modelOutputLimit,
contextAvailableTokens,

View File

@@ -83,6 +83,8 @@ export interface SessionUsageState {
totalInputTokens: number
totalOutputTokens: number
totalReasoningTokens: number
totalCacheReadTokens: number
totalCacheWriteTokens: number
totalCost: number
actualUsageTokens: number
latestMessageId?: string

View File

@@ -1,5 +1,6 @@
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import { instances } from "./instances"
import { addTaskMessage } from "./task-actions"
import { addRecentModelPreference, setAgentModelPreference } from "./preferences"
import { sessions, withSession } from "./session-state"
@@ -59,7 +60,8 @@ async function sendMessage(
sessionId: string,
prompt: string,
attachments: any[] = [],
): Promise<void> {
taskId?: string,
): Promise<string> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
@@ -72,6 +74,22 @@ async function sendMessage(
}
const messageId = createId("msg")
// If taskId is provided, associate this message with the task and set it as active
if (taskId) {
addTaskMessage(instanceId, sessionId, taskId, messageId)
withSession(instanceId, sessionId, (session) => {
session.activeTaskId = taskId
})
} else {
// If no taskId, we might want to clear activeTaskId to go back to global chat
// or keep it if we are still "in" a task view.
// For isolation, it's better to clear it if a global message is sent.
withSession(instanceId, sessionId, (session) => {
session.activeTaskId = undefined
})
}
const textPartId = createId("part")
const resolvedPrompt = resolvePastedPlaceholders(prompt, attachments)
@@ -143,6 +161,8 @@ async function sendMessage(
const store = messageStoreBus.getOrCreate(instanceId)
const createdAt = Date.now()
log.info("sendMessage: upserting optimistic message", { messageId, sessionId, taskId });
store.upsertMessage({
id: messageId,
sessionId,
@@ -177,22 +197,43 @@ async function sendMessage(
requestBody,
})
// Electron diagnostic logging
if (typeof window !== "undefined" && (window as any).electron) {
log.info("Electron environment detected", {
isElectron: true,
userAgent: navigator.userAgent,
origin: window.location.origin
})
}
try {
log.info("session.promptAsync", { instanceId, sessionId, requestBody })
log.info("session.promptAsync starting", { instanceId, sessionId })
const response = await instance.client.session.promptAsync({
path: { id: sessionId },
body: requestBody,
})
log.info("sendMessage response", response)
if (response.error) {
log.error("sendMessage server error", response.error)
throw new Error(JSON.stringify(response.error) || "Failed to send message")
}
} catch (error) {
log.error("Failed to send prompt", error)
log.info("session.promptAsync success", { instanceId, sessionId, response })
return messageId
} catch (error: any) {
log.error("Failed to send prompt", {
instanceId,
sessionId,
error: error?.message || error,
stack: error?.stack,
requestBody
})
// Update message status to error in store
const store = messageStoreBus.getOrCreate(instanceId)
store.upsertMessage({
id: messageId,
sessionId,
status: "error",
updatedAt: Date.now(),
})
throw error
}
}
@@ -363,10 +404,65 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s
})
}
async function revertSession(instanceId: string, sessionId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const session = sessions().get(instanceId)?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
try {
await instance.client.session.revert({
path: { id: sessionId },
})
} catch (error) {
log.error("Failed to revert session", error)
throw error
}
}
async function forkSession(instanceId: string, sessionId: string): Promise<string> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const session = sessions().get(instanceId)?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
try {
const response = await instance.client.session.fork({
path: { id: sessionId },
})
if (response.error) {
throw new Error(JSON.stringify(response.error) || "Failed to fork session")
}
const newSessionId = response.data?.id
if (!newSessionId) {
throw new Error("No session ID returned from fork operation")
}
return newSessionId
} catch (error) {
log.error("Failed to fork session", error)
throw error
}
}
export {
abortSession,
executeCustomCommand,
forkSession,
renameSession,
revertSession,
runShellCommand,
sendMessage,
updateSessionAgent,

View File

@@ -24,6 +24,7 @@ import {
setLoading,
cleanupBlankSessions,
} from "./session-state"
import { getInstanceConfig, ensureInstanceConfigLoaded } from "./instance-config"
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info"
@@ -79,6 +80,11 @@ async function fetchSessions(instanceId: string): Promise<void> {
const existingSessions = sessions().get(instanceId)
// Load session tasks from storage
await ensureInstanceConfigLoaded(instanceId)
const instanceData = getInstanceConfig(instanceId)
const sessionTasks = instanceData.sessionTasks || {}
for (const apiSession of response.data) {
const existingSession = existingSessions?.get(apiSession.id)
@@ -101,6 +107,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
diff: apiSession.revert.diff,
}
: undefined,
tasks: sessionTasks[apiSession.id] || [],
})
}
@@ -226,6 +233,8 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
actualUsageTokens: 0,
modelOutputLimit: initialOutputLimit,
contextAvailableTokens: initialContextAvailable,
@@ -284,7 +293,7 @@ async function forkSession(
id: info.id,
instanceId,
title: info.title || "Forked Session",
parentId: info.parentID || null,
parentId: info.parentID || sourceSessionId, // Fallback to source session to ensure parent-child relationship
agent: info.agent || "",
model: {
providerId: info.model?.providerID || "",
@@ -329,6 +338,8 @@ async function forkSession(
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
actualUsageTokens: 0,
modelOutputLimit: forkOutputLimit,
contextAvailableTokens: forkContextAvailable,

View File

@@ -17,11 +17,14 @@ import type { MessageStatus } from "./message-v2/types"
import { getLogger } from "../lib/logger"
import { showToastNotification, ToastVariant } from "../lib/notifications"
import { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances"
import { instances, addPermissionToQueue, removePermissionFromQueue, sendPermissionResponse } from "./instances"
import { getSoloState, incrementStep, popFromTaskQueue, setActiveTaskId } from "./solo-store"
import { sendMessage } from "./session-actions"
import { showAlertDialog } from "./alerts"
import { sessions, setSessions, withSession } from "./session-state"
import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info"
import { addTaskMessage, replaceTaskMessageId } from "./task-actions"
const log = getLogger("sse")
import { loadMessages } from "./session-api"
@@ -77,20 +80,20 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
if (event.type === "message.part.updated") {
const rawPart = event.properties?.part
if (!rawPart) return
const part = normalizeMessagePart(rawPart)
const messageInfo = (event as any)?.properties?.message as MessageInfo | undefined
const fallbackSessionId = typeof messageInfo?.sessionID === "string" ? messageInfo.sessionID : undefined
const fallbackMessageId = typeof messageInfo?.id === "string" ? messageInfo.id : undefined
const sessionId = typeof part.sessionID === "string" ? part.sessionID : fallbackSessionId
const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId
if (!sessionId || !messageId) return
const session = instanceSessions.get(sessionId)
if (!session) return
// Note: session may be null for newly forked sessions where SSE event arrives before session is registered
const store = messageStoreBus.getOrCreate(instanceId)
const role: MessageRole = resolveMessageRole(messageInfo)
const createdAt = typeof messageInfo?.time?.created === "number" ? messageInfo.time.created : Date.now()
@@ -101,6 +104,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
const pendingId = findPendingMessageId(store, sessionId, role)
if (pendingId && pendingId !== messageId) {
replaceMessageIdV2(instanceId, pendingId, messageId)
replaceTaskMessageId(instanceId, sessionId, pendingId, messageId)
record = store.getMessage(messageId)
}
}
@@ -115,12 +119,37 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
updatedAt: createdAt,
isEphemeral: true,
})
// Try to associate message with task
if (session?.activeTaskId) {
addTaskMessage(instanceId, sessionId, session.activeTaskId, messageId)
} else if (session?.parentId) {
// This is a task session. Find the parent and update the task.
const parentSession = instanceSessions.get(session.parentId)
if (parentSession?.tasks) {
const task = parentSession.tasks.find((t) => t.taskSessionId === sessionId)
if (task) {
addTaskMessage(instanceId, session.parentId, task.id, messageId)
}
}
} else if (!session) {
// Session not found yet - search all sessions for a task with this sessionId
for (const [, candidateSession] of instanceSessions) {
if (candidateSession.tasks) {
const task = candidateSession.tasks.find((t) => t.taskSessionId === sessionId)
if (task) {
addTaskMessage(instanceId, candidateSession.id, task.id, messageId)
break
}
}
}
}
}
if (messageInfo) {
upsertMessageInfoV2(instanceId, messageInfo, { status: "streaming" })
}
applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId })
@@ -134,18 +163,30 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
if (!sessionId || !messageId) return
const session = instanceSessions.get(sessionId)
if (!session) return
// Note: session may be null for newly forked sessions where SSE event arrives before session is registered
const store = messageStoreBus.getOrCreate(instanceId)
const role: MessageRole = info.role === "user" ? "user" : "assistant"
const hasError = Boolean((info as any).error)
const status: MessageStatus = hasError ? "error" : "complete"
// Auto-correction logic for SOLO
const solo = getSoloState(instanceId)
if (hasError && solo.isAutonomous && solo.currentStep < solo.maxSteps) {
log.info(`[SOLO] Error detected in autonomous mode, prompting for fix: ${messageId}`)
const errorMessage = (info as any).error?.message || "Unknown error"
incrementStep(instanceId)
sendMessage(instanceId, sessionId, `The previous step failed with error: ${errorMessage}. Please analyze the error and try a different approach.`, [], solo.activeTaskId || undefined).catch((err) => {
log.error("[SOLO] Failed to send error correction message", err)
})
}
let record = store.getMessage(messageId)
if (!record) {
const pendingId = findPendingMessageId(store, sessionId, role)
if (pendingId && pendingId !== messageId) {
replaceMessageIdV2(instanceId, pendingId, messageId)
replaceTaskMessageId(instanceId, sessionId, pendingId, messageId)
record = store.getMessage(messageId)
}
}
@@ -161,6 +202,31 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
createdAt,
updatedAt: completedAt ?? createdAt,
})
// Try to associate message with task
if (session?.activeTaskId) {
addTaskMessage(instanceId, sessionId, session.activeTaskId, messageId)
} else if (session?.parentId) {
// This is a task session. Find the parent and update the task.
const parentSession = instanceSessions.get(session.parentId)
if (parentSession?.tasks) {
const task = parentSession.tasks.find((t) => t.taskSessionId === sessionId)
if (task) {
addTaskMessage(instanceId, session.parentId, task.id, messageId)
}
}
} else if (!session) {
// Session not found yet - search all sessions for a task with this sessionId
for (const [, candidateSession] of instanceSessions) {
if (candidateSession.tasks) {
const task = candidateSession.tasks.find((t) => t.taskSessionId === sessionId)
if (task) {
addTaskMessage(instanceId, candidateSession.id, task.id, messageId)
break
}
}
}
}
}
upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true })
@@ -198,9 +264,9 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
time: info.time
? { ...info.time }
: {
created: Date.now(),
updated: Date.now(),
},
created: Date.now(),
updated: Date.now(),
},
} as any
setSessions((prev) => {
@@ -228,11 +294,11 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
time: mergedTime,
revert: info.revert
? {
messageID: info.revert.messageID,
partID: info.revert.partID,
snapshot: info.revert.snapshot,
diff: info.revert.diff,
}
messageID: info.revert.messageID,
partID: info.revert.partID,
snapshot: info.revert.snapshot,
diff: info.revert.diff,
}
: existingSession.revert,
}
@@ -247,11 +313,50 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
}
}
function handleSessionIdle(_instanceId: string, event: EventSessionIdle): void {
function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
const sessionId = event.properties?.sessionID
if (!sessionId) return
log.info(`[SSE] Session idle: ${sessionId}`)
// Autonomous continuation logic for SOLO
const solo = getSoloState(instanceId)
if (solo.isAutonomous && solo.currentStep < solo.maxSteps) {
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (!session) return
// If there's an active task, we might want to prompt the agent to continue or check progress
if (solo.activeTaskId) {
log.info(`[SOLO] Session idle in autonomous mode, prompting continuation for task: ${solo.activeTaskId}`)
incrementStep(instanceId)
sendMessage(instanceId, sessionId, "Continue", [], solo.activeTaskId).catch((err) => {
log.error("[SOLO] Failed to send continuation message", err)
})
} else {
// Check if there's another task in the queue
const nextTaskId = popFromTaskQueue(instanceId)
if (nextTaskId) {
log.info(`[SOLO] Session idle, starting next task from queue: ${nextTaskId}`)
// Find the task title to provide context
let taskTitle = "Start next task"
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (session?.tasks) {
const task = session.tasks.find(t => t.id === nextTaskId)
if (task) {
taskTitle = `Please start working on the task: "${task.title}". Provide a plan and begin execution.`
}
}
setActiveTaskId(instanceId, nextTaskId)
sendMessage(instanceId, sessionId, taskTitle, [], nextTaskId).catch((err) => {
log.error("[SOLO] Failed to start next task", err)
})
}
}
}
}
function handleSessionCompacted(instanceId: string, event: EventSessionCompacted): void {
@@ -284,7 +389,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
})
}
function handleSessionError(_instanceId: string, event: EventSessionError): void {
function handleSessionError(instanceId: string, event: EventSessionError): void {
const error = event.properties?.error
log.error(`[SSE] Session error:`, error)
@@ -298,10 +403,21 @@ function handleSessionError(_instanceId: string, event: EventSessionError): void
}
}
showAlertDialog(`Error: ${message}`, {
title: "Session error",
variant: "error",
})
// Autonomous error recovery for SOLO
const solo = getSoloState(instanceId)
const sessionId = (event.properties as any)?.sessionID
if (solo.isAutonomous && sessionId && solo.currentStep < solo.maxSteps) {
log.info(`[SOLO] Session error in autonomous mode, prompting fix: ${message}`)
incrementStep(instanceId)
sendMessage(instanceId, sessionId, `I encountered an error: "${message}". Please analyze the cause and provide a fix.`, [], solo.activeTaskId || undefined).catch((err) => {
log.error("[SOLO] Failed to send error recovery message", err)
})
} else {
showAlertDialog(`Error: ${message}`, {
title: "Session error",
variant: "error",
})
}
}
function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void {
@@ -344,6 +460,18 @@ function handlePermissionUpdated(instanceId: string, event: EventPermissionUpdat
log.info(`[SSE] Permission updated: ${permission.id} (${permission.type})`)
addPermissionToQueue(instanceId, permission)
upsertPermissionV2(instanceId, permission)
// Auto-approval logic for SOLO autonomous agent
const solo = getSoloState(instanceId)
if (solo.isAutonomous && solo.autoApproval) {
log.info(`[SOLO] Auto-approving permission: ${permission.id}`)
const sessionId = permission.sessionID
if (sessionId) {
sendPermissionResponse(instanceId, sessionId, permission.id, "always").catch((err) => {
log.error(`[SOLO] Failed to auto-approve permission ${permission.id}`, err)
})
}
}
}
function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void {

View File

@@ -7,6 +7,7 @@ import { messageStoreBus } from "./message-v2/bus"
import { instances } from "./instances"
import { showConfirmDialog } from "./alerts"
import { getLogger } from "../lib/logger"
import { updateInstanceConfig } from "./instance-config"
const log = getLogger("session")
@@ -17,6 +18,8 @@ export interface SessionInfo {
inputTokens: number
outputTokens: number
reasoningTokens: number
cacheReadTokens: number
cacheWriteTokens: number
actualUsageTokens: number
modelOutputLimit: number
contextAvailableTokens: number | null
@@ -150,6 +153,29 @@ function withSession(instanceId: string, sessionId: string, updater: (session: S
next.set(instanceId, newInstanceSessions)
return next
})
// Persist session tasks to storage
persistSessionTasks(instanceId)
}
async function persistSessionTasks(instanceId: string) {
try {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
const sessionTasks: Record<string, any[]> = {}
for (const [sessionId, session] of instanceSessions) {
if (session.tasks && session.tasks.length > 0) {
sessionTasks[sessionId] = session.tasks
}
}
await updateInstanceConfig(instanceId, (draft) => {
draft.sessionTasks = sessionTasks
})
} catch (error) {
log.error("Failed to persist session tasks", error)
}
}
function setSessionCompactionState(instanceId: string, sessionId: string, isCompacting: boolean): void {
@@ -378,6 +404,7 @@ export {
clearInstanceDraftPrompts,
pruneDraftPrompts,
withSession,
persistSessionTasks,
setSessionCompactionState,
setSessionPendingPermission,
setActiveSession,

View File

@@ -0,0 +1,77 @@
import { createSignal } from "solid-js"
import { getLogger } from "../lib/logger"
const log = getLogger("solo")
export interface SoloState {
isAutonomous: boolean
autoApproval: boolean
maxSteps: number
currentStep: number
activeTaskId: string | null
taskQueue: string[]
}
const [soloStates, setSoloStates] = createSignal<Map<string, SoloState>>(new Map())
export function getSoloState(instanceId: string): SoloState {
const state = soloStates().get(instanceId)
if (!state) {
return {
isAutonomous: false,
autoApproval: false,
maxSteps: 50,
currentStep: 0,
activeTaskId: null,
taskQueue: [],
}
}
return state
}
export function setSoloState(instanceId: string, partial: Partial<SoloState>) {
setSoloStates((prev) => {
const next = new Map(prev)
const current = getSoloState(instanceId)
next.set(instanceId, { ...current, ...partial })
return next
})
}
export function toggleAutonomous(instanceId: string) {
const current = getSoloState(instanceId)
setSoloState(instanceId, { isAutonomous: !current.isAutonomous })
log.info(`Autonomous mode ${!current.isAutonomous ? "enabled" : "disabled"} for ${instanceId}`)
}
export function toggleAutoApproval(instanceId: string) {
const current = getSoloState(instanceId)
setSoloState(instanceId, { autoApproval: !current.autoApproval })
log.info(`Auto-approval ${!current.autoApproval ? "enabled" : "disabled"} for ${instanceId}`)
}
export function incrementStep(instanceId: string) {
const state = getSoloState(instanceId)
setSoloState(instanceId, { currentStep: state.currentStep + 1 })
}
export function resetSteps(instanceId: string) {
setSoloState(instanceId, { currentStep: 0 })
}
export function setActiveTaskId(instanceId: string, taskId: string | null) {
setSoloState(instanceId, { activeTaskId: taskId })
}
export function addToTaskQueue(instanceId: string, taskId: string) {
const current = getSoloState(instanceId)
setSoloState(instanceId, { taskQueue: [...current.taskQueue, taskId] })
}
export function popFromTaskQueue(instanceId: string): string | null {
const current = getSoloState(instanceId)
if (current.taskQueue.length === 0) return null
const [next, ...rest] = current.taskQueue
setSoloState(instanceId, { taskQueue: rest })
return next
}

View File

@@ -0,0 +1,163 @@
import { withSession } from "./session-state"
import { Task, TaskStatus } from "../types/session"
import { nanoid } from "nanoid"
import { forkSession } from "./session-api"
export function setActiveTask(instanceId: string, sessionId: string, taskId: string | undefined): void {
withSession(instanceId, sessionId, (session) => {
session.activeTaskId = taskId
})
}
export async function addTask(
instanceId: string,
sessionId: string,
title: string
): Promise<{ id: string; taskSessionId?: string }> {
const id = nanoid()
console.log("[task-actions] addTask started", { instanceId, sessionId, title, taskId: id });
let taskSessionId: string | undefined
try {
console.log("[task-actions] forking session...");
const forked = await forkSession(instanceId, sessionId)
taskSessionId = forked.id
console.log("[task-actions] fork successful", { taskSessionId });
} catch (error) {
console.error("[task-actions] Failed to fork session for task", error)
}
const newTask: Task = {
id,
title,
status: "pending",
timestamp: Date.now(),
messageIds: [],
taskSessionId,
}
withSession(instanceId, sessionId, (session) => {
if (!session.tasks) {
session.tasks = []
}
session.tasks = [newTask, ...session.tasks]
console.log("[task-actions] task added to session", { taskCount: session.tasks.length });
})
return { id, taskSessionId }
}
export function addTaskMessage(
instanceId: string,
sessionId: string,
taskId: string,
messageId: string,
): void {
console.log("[task-actions] addTaskMessage called", { instanceId, sessionId, taskId, messageId });
withSession(instanceId, sessionId, (session) => {
let targetSessionId = sessionId
let targetTaskId = taskId
// If this is a child session, the tasks are on the parent
if (session.parentId && !session.tasks) {
targetSessionId = session.parentId
console.log("[task-actions] task session detected, targeting parent", { parentId: session.parentId });
}
withSession(instanceId, targetSessionId, (targetSession) => {
if (!targetSession.tasks) {
console.warn("[task-actions] target session has no tasks array", { targetSessionId });
return
}
const taskIndex = targetSession.tasks.findIndex((t) => t.id === targetTaskId || t.taskSessionId === sessionId)
if (taskIndex !== -1) {
const task = targetSession.tasks[taskIndex]
const messageIds = [...(task.messageIds || [])]
if (!messageIds.includes(messageId)) {
messageIds.push(messageId)
// Replace the task object and the tasks array to trigger reactivity
const updatedTask = { ...task, messageIds }
const updatedTasks = [...targetSession.tasks]
updatedTasks[taskIndex] = updatedTask
targetSession.tasks = updatedTasks
console.log("[task-actions] message ID added to task with reactivity", { taskId: task.id, messageCount: messageIds.length });
} else {
console.log("[task-actions] message ID already in task", { taskId: task.id });
}
} else {
console.warn("[task-actions] task not found in session", { targetTaskId, sessionId, availableTaskCount: targetSession.tasks.length });
}
})
})
}
export function replaceTaskMessageId(
instanceId: string,
sessionId: string,
oldMessageId: string,
newMessageId: string,
): void {
withSession(instanceId, sessionId, (session) => {
let targetSessionId = sessionId
if (session.parentId && !session.tasks) {
targetSessionId = session.parentId
}
withSession(instanceId, targetSessionId, (targetSession) => {
if (!targetSession.tasks) return
const taskIndex = targetSession.tasks.findIndex((t) =>
t.messageIds?.includes(oldMessageId) || t.taskSessionId === sessionId
)
if (taskIndex !== -1) {
const task = targetSession.tasks[taskIndex]
const messageIds = [...(task.messageIds || [])]
const index = messageIds.indexOf(oldMessageId)
let changed = false
if (index !== -1) {
messageIds[index] = newMessageId
changed = true
} else if (task.taskSessionId === sessionId && !messageIds.includes(newMessageId)) {
messageIds.push(newMessageId)
changed = true
}
if (changed) {
const updatedTask = { ...task, messageIds }
const updatedTasks = [...targetSession.tasks]
updatedTasks[taskIndex] = updatedTask
targetSession.tasks = updatedTasks
}
}
})
})
}
export function updateTaskStatus(
instanceId: string,
sessionId: string,
taskId: string,
status: TaskStatus,
): void {
withSession(instanceId, sessionId, (session) => {
if (!session.tasks) return
session.tasks = session.tasks.map((t) => (t.id === taskId ? { ...t, status } : t))
})
}
export function removeTask(instanceId: string, sessionId: string, taskId: string): void {
withSession(instanceId, sessionId, (session) => {
if (!session.tasks) return
session.tasks = session.tasks.filter((t) => t.id !== taskId)
if (session.activeTaskId === taskId) {
session.activeTaskId = undefined
}
})
}