diff --git a/packages/opencode-config/plugin/lib/background-process.ts b/packages/opencode-config/plugin/lib/background-process.ts index 689ce519..15198288 100644 --- a/packages/opencode-config/plugin/lib/background-process.ts +++ b/packages/opencode-config/plugin/lib/background-process.ts @@ -13,6 +13,11 @@ type BackgroundProcess = { outputSizeBytes?: number } +type BackgroundProcessNotificationRequest = { + sessionID: string + directory: string +} + type BackgroundProcessOptions = { baseDir: string } @@ -36,12 +41,19 @@ export function createBackgroundProcessTools(config: CodeNomadConfig, options: B args: { title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"), command: tool.schema.string().describe("Shell command to run in the workspace"), + notify: tool.schema.boolean().optional().describe("Notify the current session when the process ends"), }, - async execute(args) { + async execute(args, context) { assertCommandWithinBase(args.command, options.baseDir) + const notification: BackgroundProcessNotificationRequest | undefined = args.notify + ? { + sessionID: context.sessionID, + directory: context.directory, + } + : undefined const process = await request("", { method: "POST", - body: JSON.stringify({ title: args.title, command: args.command }), + body: JSON.stringify({ title: args.title, command: args.command, notify: args.notify, notification }), }) return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}` diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index b54676a6..c024cb53 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -376,6 +376,8 @@ export interface ServerMeta { export type BackgroundProcessStatus = "running" | "stopped" | "error" +export type BackgroundProcessTerminalReason = "finished" | "failed" | "user_stopped" | "user_terminated" + export interface BackgroundProcess { id: string workspaceId: string @@ -388,6 +390,7 @@ export interface BackgroundProcess { stoppedAt?: string exitCode?: number outputSizeBytes?: number + terminalReason?: BackgroundProcessTerminalReason } export interface BackgroundProcessListResponse { diff --git a/packages/server/src/background-processes/manager.ts b/packages/server/src/background-processes/manager.ts index 53fdf919..37f2afbf 100644 --- a/packages/server/src/background-processes/manager.ts +++ b/packages/server/src/background-processes/manager.ts @@ -5,7 +5,7 @@ import { randomBytes } from "crypto" import type { EventBus } from "../events/bus" import type { WorkspaceManager } from "../workspaces/manager" import type { Logger } from "../logger" -import type { BackgroundProcess, BackgroundProcessStatus } from "../api-types" +import type { BackgroundProcess, BackgroundProcessStatus, BackgroundProcessTerminalReason } from "../api-types" const ROOT_DIR = ".codenomad/background_processes" const INDEX_FILE = "index.json" @@ -27,6 +27,31 @@ interface RunningProcess { outputPath: string exitPromise: Promise workspaceId: string + completion?: ProcessCompletion +} + +interface ProcessCompletion { + reason: BackgroundProcessTerminalReason + endContext: "normal" | "workspace_cleanup" + removeAfterFinalize?: boolean +} + +interface BackgroundProcessNotificationState { + sessionID: string + directory: string + sentAt?: string +} + +interface PersistedBackgroundProcess extends BackgroundProcess { + notify?: BackgroundProcessNotificationState +} + +interface StartOptions { + notify?: boolean + notification?: { + sessionID: string + directory: string + } } export class BackgroundProcessManager { @@ -41,14 +66,14 @@ export class BackgroundProcessManager { const records = await this.readIndex(workspaceId) const enriched = await Promise.all( records.map(async (record) => ({ - ...record, + ...this.toPublicProcess(record), outputSizeBytes: await this.getOutputSize(workspaceId, record.id), })), ) return enriched } - async start(workspaceId: string, title: string, command: string): Promise { + async start(workspaceId: string, title: string, command: string, options: StartOptions = {}): Promise { const workspace = this.deps.workspaceManager.get(workspaceId) if (!workspace) { throw new Error("Workspace not found") @@ -73,8 +98,7 @@ export class BackgroundProcessManager { this.killProcessTree(child, "SIGTERM") }) - const record: BackgroundProcess = { - + const record: PersistedBackgroundProcess = { id, workspaceId, title, @@ -84,6 +108,20 @@ export class BackgroundProcessManager { pid: child.pid, startedAt: new Date().toISOString(), outputSizeBytes: 0, + notify: options.notify && options.notification + ? { + sessionID: options.notification.sessionID, + directory: options.notification.directory, + } + : undefined, + } + + const runningState: RunningProcess = { + id, + child, + outputPath, + exitPromise: Promise.resolve(), + workspaceId, } const exitPromise = new Promise((resolve) => { @@ -91,18 +129,21 @@ export class BackgroundProcessManager { await new Promise((resolve) => outputStream.end(resolve)) this.running.delete(id) - record.status = this.statusFromExit(code) + const completion = runningState.completion ?? this.completionFromExit(code) + + record.terminalReason = completion.reason + record.status = this.statusFromReason(completion.reason) record.exitCode = code === null ? undefined : code record.stoppedAt = new Date().toISOString() - await this.upsertIndex(workspaceId, record) - record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id) - this.publishUpdate(workspaceId, record) + await this.finalizeRecord(workspaceId, record, completion) resolve() }) }) - this.running.set(id, { id, child, outputPath, exitPromise, workspaceId }) + runningState.exitPromise = exitPromise + + this.running.set(id, runningState) let lastPublishAt = 0 const maybePublishSize = () => { @@ -128,7 +169,7 @@ export class BackgroundProcessManager { await this.upsertIndex(workspaceId, record) record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id) this.publishUpdate(workspaceId, record) - return record + return this.toPublicProcess(record) } async stop(workspaceId: string, processId: string): Promise { @@ -139,19 +180,21 @@ export class BackgroundProcessManager { const running = this.running.get(processId) if (running?.child && !running.child.killed) { + running.completion = { reason: "user_stopped", endContext: "normal" } this.killProcessTree(running.child, "SIGTERM") await this.waitForExit(running) + const updated = await this.findProcess(workspaceId, processId) + return updated ? this.toPublicProcess(updated) : this.toPublicProcess(record) } if (record.status === "running") { record.status = "stopped" + record.terminalReason = "user_stopped" record.stoppedAt = new Date().toISOString() - await this.upsertIndex(workspaceId, record) - record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id) - this.publishUpdate(workspaceId, record) + await this.finalizeRecord(workspaceId, record, { reason: "user_stopped", endContext: "normal" }) } - return record + return this.toPublicProcess(record) } async terminate(workspaceId: string, processId: string): Promise { @@ -160,17 +203,19 @@ export class BackgroundProcessManager { const running = this.running.get(processId) if (running?.child && !running.child.killed) { + running.completion = { reason: "user_terminated", endContext: "normal", removeAfterFinalize: true } this.killProcessTree(running.child, "SIGTERM") await this.waitForExit(running) + return } - await this.removeFromIndex(workspaceId, processId) - await this.removeProcessDir(workspaceId, processId) - - this.deps.eventBus.publish({ - type: "instance.event", - instanceId: workspaceId, - event: { type: "background.process.removed", properties: { processId } }, + record.status = "stopped" + record.terminalReason = "user_terminated" + record.stoppedAt = new Date().toISOString() + await this.finalizeRecord(workspaceId, record, { + reason: "user_terminated", + endContext: "normal", + removeAfterFinalize: true, }) } @@ -266,6 +311,11 @@ export class BackgroundProcessManager { private async cleanupWorkspace(workspaceId: string) { for (const [, running] of this.running.entries()) { if (running.workspaceId !== workspaceId) continue + running.completion = { + reason: "user_terminated", + endContext: "workspace_cleanup", + removeAfterFinalize: true, + } this.killProcessTree(running.child, "SIGTERM") await this.waitForExit(running) } @@ -356,10 +406,17 @@ export class BackgroundProcessManager { return args } - private statusFromExit(code: number | null): BackgroundProcessStatus { - if (code === null) return "stopped" - if (code === 0) return "stopped" - return "error" + private completionFromExit(code: number | null): ProcessCompletion { + if (code === 0) { + return { reason: "finished", endContext: "normal" } + } + + return { reason: "failed", endContext: "normal" } + } + + private statusFromReason(reason: BackgroundProcessTerminalReason): BackgroundProcessStatus { + if (reason === "failed") return "error" + return "stopped" } private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise { @@ -423,25 +480,25 @@ export class BackgroundProcessManager { return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE) } - private async findProcess(workspaceId: string, processId: string): Promise { + private async findProcess(workspaceId: string, processId: string): Promise { const records = await this.readIndex(workspaceId) return records.find((entry) => entry.id === processId) ?? null } - private async readIndex(workspaceId: string): Promise { + private async readIndex(workspaceId: string): Promise { const indexPath = await this.getIndexPath(workspaceId) if (!existsSync(indexPath)) return [] try { const raw = await fs.readFile(indexPath, "utf-8") const parsed = JSON.parse(raw) - return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : [] + return Array.isArray(parsed) ? (parsed as PersistedBackgroundProcess[]) : [] } catch { return [] } } - private async upsertIndex(workspaceId: string, record: BackgroundProcess) { + private async upsertIndex(workspaceId: string, record: PersistedBackgroundProcess) { const records = await this.readIndex(workspaceId) const index = records.findIndex((entry) => entry.id === record.id) if (index >= 0) { @@ -458,7 +515,7 @@ export class BackgroundProcessManager { await this.writeIndex(workspaceId, next) } - private async writeIndex(workspaceId: string, records: BackgroundProcess[]) { + private async writeIndex(workspaceId: string, records: PersistedBackgroundProcess[]) { const indexPath = await this.getIndexPath(workspaceId) await fs.mkdir(path.dirname(indexPath), { recursive: true }) await fs.writeFile(indexPath, JSON.stringify(records, null, 2)) @@ -503,14 +560,138 @@ export class BackgroundProcessManager { } } - private publishUpdate(workspaceId: string, record: BackgroundProcess) { + private publishUpdate(workspaceId: string, record: PersistedBackgroundProcess) { this.deps.eventBus.publish({ type: "instance.event", instanceId: workspaceId, - event: { type: "background.process.updated", properties: { process: record } }, + event: { type: "background.process.updated", properties: { process: this.toPublicProcess(record) } }, }) } + private toPublicProcess(record: PersistedBackgroundProcess): BackgroundProcess { + return { + id: record.id, + workspaceId: record.workspaceId, + title: record.title, + command: record.command, + cwd: record.cwd, + status: record.status, + pid: record.pid, + startedAt: record.startedAt, + stoppedAt: record.stoppedAt, + exitCode: record.exitCode, + outputSizeBytes: record.outputSizeBytes, + terminalReason: record.terminalReason, + } + } + + private async finalizeRecord(workspaceId: string, record: PersistedBackgroundProcess, completion: ProcessCompletion) { + if (this.shouldSendCompletionPrompt(record, completion)) { + try { + await this.sendCompletionPrompt(workspaceId, record) + if (record.notify) { + record.notify.sentAt = new Date().toISOString() + } + } catch (error) { + this.deps.logger.warn({ err: error, workspaceId, processId: record.id }, "Failed to send background process completion prompt") + } + } + + if (completion.removeAfterFinalize) { + await this.removeFromIndex(workspaceId, record.id) + await this.removeProcessDir(workspaceId, record.id) + + this.deps.eventBus.publish({ + type: "instance.event", + instanceId: workspaceId, + event: { type: "background.process.removed", properties: { processId: record.id } }, + }) + return + } + + await this.upsertIndex(workspaceId, record) + record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id) + this.publishUpdate(workspaceId, record) + } + + private shouldSendCompletionPrompt(record: PersistedBackgroundProcess, completion: ProcessCompletion) { + if (completion.endContext === "workspace_cleanup") return false + if (!record.notify) return false + return !record.notify.sentAt + } + + private async sendCompletionPrompt(workspaceId: string, record: PersistedBackgroundProcess) { + const notify = record.notify + if (!notify || !record.terminalReason) return + + if (!this.deps.workspaceManager.get(workspaceId)) { + throw new Error("Workspace not found") + } + + const port = this.deps.workspaceManager.getInstancePort(workspaceId) + if (!port) { + throw new Error("Workspace instance is not ready") + } + + const targetUrl = `http://127.0.0.1:${port}/session/${encodeURIComponent(notify.sessionID)}/prompt_async` + const headers: Record = { + "content-type": "application/json", + "x-opencode-directory": /[^\x00-\x7F]/.test(notify.directory) ? encodeURIComponent(notify.directory) : notify.directory, + } + + const authorization = this.deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId) + if (authorization) { + headers.authorization = authorization + } + + const response = await fetch(targetUrl, { + method: "POST", + headers, + body: JSON.stringify({ + parts: [ + { + type: "text", + text: this.buildSyntheticCompletionPrompt(record), + synthetic: true, + }, + ], + }), + }) + + if (!response.ok) { + const message = await response.text().catch(() => "") + throw new Error(message || `Prompt request failed with ${response.status}`) + } + } + + private buildCompletionPrompt(record: PersistedBackgroundProcess): string { + const ref = `Background process "${record.title}" (${record.id})` + + switch (record.terminalReason) { + case "finished": + return `${ref} finished successfully.` + case "failed": + return record.exitCode === undefined ? `${ref} failed.` : `${ref} failed with exit code ${record.exitCode}.` + case "user_stopped": + return `${ref} was stopped by user.` + case "user_terminated": + return `${ref} was terminated by user.` + } + + return `${ref} ended.` + } + + private buildSyntheticCompletionPrompt(record: PersistedBackgroundProcess): string { + return `${this.escapeTaggedText(this.buildCompletionPrompt(record))}` + } + + private escapeTaggedText(input: string): string { + return input + .replace(/&/g, "&") + .replace(//g, ">") + } + private generateId(): string { const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15) const random = randomBytes(3).toString("hex") diff --git a/packages/server/src/server/routes/background-processes.ts b/packages/server/src/server/routes/background-processes.ts index c9520416..df7bfca3 100644 --- a/packages/server/src/server/routes/background-processes.ts +++ b/packages/server/src/server/routes/background-processes.ts @@ -9,6 +9,21 @@ interface RouteDeps { const StartSchema = z.object({ title: z.string().trim().min(1), command: z.string().trim().min(1), + notify: z.boolean().optional(), + notification: z + .object({ + sessionID: z.string().trim().min(1), + directory: z.string().trim().min(1), + }) + .optional(), +}).superRefine((value, ctx) => { + if (value.notify && !value.notification) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Notification metadata is required when notify is enabled", + path: ["notification"], + }) + } }) const OutputQuerySchema = z.object({ @@ -27,7 +42,10 @@ export function registerBackgroundProcessRoutes(app: FastifyInstance, deps: Rout app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => { const payload = StartSchema.parse(request.body ?? {}) - const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command) + const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command, { + notify: payload.notify, + notification: payload.notification, + }) reply.code(201) return process }) diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index 6bea6b99..211127a9 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -3,7 +3,7 @@ import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, import MessageItem from "./message-item" import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { ClientPart, MessageInfo } from "../types/message" -import { partHasRenderableText } from "../types/message" +import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message" import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache" import type { MessageRecord } from "../stores/message-v2/types" import { messageStoreBus } from "../stores/message-v2/bus" @@ -231,6 +231,12 @@ function isContentPartType(type: unknown): boolean { return type === "text" || type === "file" } +function isVisibleContentPart(part: ClientPart): boolean { + if (!part || !isContentPartType((part as any).type)) return false + if (isHiddenSyntheticTextPart(part)) return false + return partHasRenderableText(part) +} + function MessageContentItem(props: MessageContentItemProps) { const record = createMemo(() => props.store().getMessage(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) @@ -264,13 +270,15 @@ function MessageContentItem(props: MessageContentItemProps) { return resolved }) + const visibleParts = createMemo(() => parts().filter((part) => isVisibleContentPart(part))) + const showAgentMeta = createMemo(() => { const current = record() if (!current) return false if (current.role !== "assistant") return false const currentParts = parts() - if (!currentParts.some((part) => partHasRenderableText(part))) { + if (visibleParts().length === 0) { return false } @@ -286,10 +294,10 @@ function MessageContentItem(props: MessageContentItemProps) { if (!isSupportedPartType(part)) continue if (!isContentPartType((part as any).type)) continue - if (partHasRenderableText(part)) { - return false + if (isVisibleContentPart(part)) { + return false + } } - } return true }) @@ -300,7 +308,7 @@ function MessageContentItem(props: MessageContentItemProps) { { return props.parts - .filter(part => part.type === "text") - .map(part => (part as { text?: string }).text || "") - .filter(text => text.trim().length > 0) + .filter((part) => part.type === "text" && !isHiddenSyntheticTextPart(part)) + .map((part) => (part as { text?: string }).text || "") + .filter((text) => text.trim().length > 0) .join("\n\n") } @@ -338,7 +338,7 @@ export default function MessageItem(props: MessageItemProps) { } } - if (!isUser() && !hasContent() && !isGenerating()) { + if (!hasContent() && !isGenerating()) { return null } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index c0bd07cf..b51a5282 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -33,19 +33,7 @@ export default function MessagePart(props: MessagePartProps) { const shouldHideTextPart = () => { const part = props.part if (!part || part.type !== "text") return false - - const isSynthetic = Boolean((part as any).synthetic) - if (!isSynthetic) return false - - // Keep optimistic user prompts visible; hide other synthetic user helper parts. - if (props.messageType === "user") { - const primaryId = props.primaryUserTextPartId - if (!primaryId) return false - return part.id !== primaryId - } - - // Hide synthetic assistant text. - return true + return Boolean((part as any).synthetic) } diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index b3f81be3..159efbc1 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -46,6 +46,33 @@ export default function MessageSection(props: MessageSectionProps) { const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId)) const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId)) + const visibleMessageIds = createMemo(() => { + const resolvedStore = store() + return messageIds().filter((messageId) => { + const record = resolvedStore.getMessage(messageId) + if (!record) return false + + if (buildTimelineSegments(props.instanceId, record, t).length > 0) { + return true + } + + if (record.role !== "assistant") { + return false + } + + const info = resolvedStore.getMessageInfo(messageId) + if (!info || info.role !== "assistant") { + return false + } + + if (info.error) { + return true + } + + const timeInfo = info.time as { created: number; end?: number } | undefined + return Boolean(timeInfo && (timeInfo.end === undefined || timeInfo.end === 0)) + }) + }) const scrollCache = useScrollCache({ instanceId: props.instanceId, @@ -611,7 +638,7 @@ export default function MessageSection(props: MessageSectionProps) { const api = listApi() if (!element || !api) return if (props.loading) return - if (messageIds().length === 0) return + if (visibleMessageIds().length === 0) return if (didRestoreScroll()) return scrollCache.restore(element, { @@ -1003,7 +1030,7 @@ export default function MessageSection(props: MessageSectionProps) { data-scroll-buttons={scrollButtonsCount()} > messageId} getAnchorId={getMessageAnchorId} getKeyFromAnchorId={getMessageIdFromAnchorId} @@ -1049,7 +1076,7 @@ export default function MessageSection(props: MessageSectionProps) { registerState={(state) => setListState(state)} renderBeforeItems={() => ( <> - +
diff --git a/packages/ui/src/components/message-timeline.tsx b/packages/ui/src/components/message-timeline.tsx index 7df1eba0..0d95c0f2 100644 --- a/packages/ui/src/components/message-timeline.tsx +++ b/packages/ui/src/components/message-timeline.tsx @@ -2,6 +2,7 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untra import MessagePreview from "./message-preview" import { messageStoreBus } from "../stores/message-v2/bus" import type { ClientPart } from "../types/message" +import { isHiddenSyntheticTextPart } from "../types/message" import type { MessageRecord } from "../stores/message-v2/types" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { getPartCharCount } from "../lib/token-utils" @@ -105,6 +106,7 @@ function collectReasoningText(part: ClientPart): string { function collectTextFromPart(part: ClientPart, t: (key: string, params?: Record) => string): string { if (!part) return "" + if (isHiddenSyntheticTextPart(part)) return "" if (typeof (part as any).text === "string") { return (part as any).text as string } diff --git a/packages/ui/src/types/message.ts b/packages/ui/src/types/message.ts index e85fe0e0..95fec10d 100644 --- a/packages/ui/src/types/message.ts +++ b/packages/ui/src/types/message.ts @@ -78,6 +78,10 @@ export interface TextPart { export type MessageInfo = SDKMessage +export function isHiddenSyntheticTextPart(part: ClientPart): boolean { + return Boolean(part && part.type === "text" && part.synthetic) +} + function hasTextSegment(segment: string | { text?: string }): boolean { if (typeof segment === "string") { return segment.trim().length > 0 @@ -95,6 +99,10 @@ export function partHasRenderableText(part: ClientPart): boolean { return false } + if (isHiddenSyntheticTextPart(part)) { + return false + } + const typedPart = part as SDKPart if (typedPart.type === "text" && hasTextSegment(typedPart.text)) {