Skip to content
This repository was archived by the owner on Jun 1, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setComposing(false)
requestAnimationFrame(() => {
if (composing()) return
reconcile(prompt.current().filter((part) => part.type !== "image"))
reconcile(prompt.current())
})
}

Expand Down
10 changes: 9 additions & 1 deletion packages/app/src/context/global-sdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ const globalSdkContext = createSimpleContext({
const aborted = (error: unknown) => abortError.safeParse(error).success

let attempt: AbortController | undefined
let currentOnAbort: (() => void) | undefined
let run: Promise<void> | undefined
let started = false
const HEARTBEAT_TIMEOUT_MS = 45_000
Expand All @@ -128,11 +129,15 @@ const globalSdkContext = createSimpleContext({
run = (async () => {
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
while (!abort.signal.aborted && started) {
if (currentOnAbort) {
abort.signal.removeEventListener("abort", currentOnAbort)
}
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
currentOnAbort = onAbort
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
Expand Down Expand Up @@ -183,7 +188,10 @@ const globalSdkContext = createSimpleContext({
})
}
} finally {
abort.signal.removeEventListener("abort", onAbort)
if (currentOnAbort) {
abort.signal.removeEventListener("abort", currentOnAbort)
}
currentOnAbort = undefined
attempt = undefined
clearHeartbeat()
}
Expand Down
29 changes: 21 additions & 8 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1055,7 +1055,7 @@ export default function Page() {
on(
() => visibleUserMessages().at(-1)?.id,
(lastId, prevLastId) => {
if (lastId && prevLastId && lastId > prevLastId) {
if (lastId && prevLastId && lastId > prevLastId && !autoScroll.userScrolled()) {
setStore("messageId", undefined)
}
},
Expand Down Expand Up @@ -1479,6 +1479,8 @@ export default function Page() {
if (!tree.reviewScroll) return
if (!reviewReady()) return

const frames: number[] = []

const attempt = (count: number) => {
if (tree.pendingDiff !== pending) return
if (count > 60) {
Expand All @@ -1488,18 +1490,21 @@ export default function Page() {

const root = tree.reviewScroll
if (!root) {
requestAnimationFrame(() => attempt(count + 1))
const id = requestAnimationFrame(() => attempt(count + 1))
frames.push(id)
return
}

if (!scrollToReviewDiff(pending)) {
requestAnimationFrame(() => attempt(count + 1))
const id = requestAnimationFrame(() => attempt(count + 1))
frames.push(id)
return
}

const top = reviewDiffTop(pending)
if (top === undefined) {
requestAnimationFrame(() => attempt(count + 1))
const id = requestAnimationFrame(() => attempt(count + 1))
frames.push(id)
return
}

Expand All @@ -1508,10 +1513,16 @@ export default function Page() {
return
}

requestAnimationFrame(() => attempt(count + 1))
const id = requestAnimationFrame(() => attempt(count + 1))
frames.push(id)
}

requestAnimationFrame(() => attempt(0))
const startId = requestAnimationFrame(() => attempt(0))
frames.push(startId)

onCleanup(() => {
for (const id of frames) cancelAnimationFrame(id)
})
})

createEffect(() => {
Expand Down Expand Up @@ -2031,8 +2042,10 @@ export default function Page() {
setFollowup("edit", id, undefined)
}

const halt = (sessionID: string) =>
busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
const halt = async (sessionID: string) => {
if (!busy(sessionID)) return
await sdk.client.session.abort({ sessionID }).catch(() => undefined)
}

const revertMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; messageID: string }) => {
Expand Down
23 changes: 14 additions & 9 deletions packages/app/src/utils/server-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ const UNREACHABLE: ServerAuthStatus = {

function basicAuthHeader(server: ServerConnection.HttpBase): string | undefined {
if (!server.password) return
return `Basic ${btoa(`${server.username ?? "codeplane"}:${server.password}`)}`
const credential = `${server.username ?? "codeplane"}:${server.password}`
return `Basic ${Buffer.from(credential).toString("base64")}`
}
Comment on lines 25 to 29

function credentialsFor(server: ServerConnection.HttpBase): RequestCredentials | undefined {
Expand All @@ -48,10 +49,12 @@ export async function checkServerAuth(
const base = server.url.replace(/\/+$/, "")
const auth = basicAuthHeader(server)

const controller = opts?.signal ? undefined : new AbortController()
const timer =
controller && opts?.timeoutMs ? setTimeout(() => controller.abort(), opts.timeoutMs) : undefined
const signal = opts?.signal ?? controller?.signal
const controller = new AbortController()
const signal = opts?.signal ?? controller.signal
if (opts?.signal) {
opts.signal.addEventListener("abort", () => controller.abort(), { once: true })
}
const timer = opts?.timeoutMs ? setTimeout(() => controller.abort(), opts.timeoutMs) : undefined
Comment on lines +52 to +57

try {
const headers: Record<string, string> = {}
Expand Down Expand Up @@ -115,10 +118,12 @@ export async function verifyTotp(
const auth = basicAuthHeader(server)
if (!auth) return { ok: false, reason: "unauthorized" }

const controller = opts?.signal ? undefined : new AbortController()
const timer =
controller && opts?.timeoutMs ? setTimeout(() => controller.abort(), opts.timeoutMs) : undefined
const signal = opts?.signal ?? controller?.signal
const controller = new AbortController()
const signal = opts?.signal ?? controller.signal
if (opts?.signal) {
opts.signal.addEventListener("abort", () => controller.abort(), { once: true })
}
const timer = opts?.timeoutMs ? setTimeout(() => controller.abort(), opts.timeoutMs) : undefined
Comment on lines +121 to +126

try {
const res = await fetcher(`${base}/global/auth/verify`, {
Expand Down
5 changes: 4 additions & 1 deletion packages/codeplane/src/cli/cmd/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -986,7 +986,10 @@ export const InstanceSignInCommand = cmd({
UI.println(UI.Style.TEXT_DIM + "(Empty line cancels.)")
UI.println(UI.Style.TEXT_NORMAL + "")

await open(saved!.url).catch(() => undefined)
await open(saved!.url).catch((e) => {
UI.println(UI.Style.TEXT_WARNING_BOLD + `Could not open browser automatically: ${e instanceof Error ? e.message : String(e)}`)
UI.println(UI.Style.TEXT_NORMAL + `Open this URL manually: ${saved!.url}`)
})

const headerLine = await new Promise<string>((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout })
Expand Down
15 changes: 13 additions & 2 deletions packages/codeplane/src/server/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ const app = (upgrade: UpgradeWebSocket) =>
const url = c.req.header("x-codeplane-proxy-url")
const queue: Msg[] = []
let remote: WebSocket | undefined
let closed = false
const closeRemote = () => {
if (closed) return
closed = true
if (remote && remote.readyState < WebSocket.CLOSING) {
remote.close()
}
remote = undefined
}
return {
onOpen(_, ws) {
if (!url) {
Expand All @@ -82,7 +91,9 @@ const app = (upgrade: UpgradeWebSocket) =>
ws.close(1011, "proxy error")
}
remote.onclose = (event) => {
ws.close(event.code, event.reason)
if (remote?.readyState !== WebSocket.CLOSED) {
ws.close(event.code, event.reason)
}
}
Comment on lines 93 to 97
},
onMessage(event) {
Expand All @@ -95,7 +106,7 @@ const app = (upgrade: UpgradeWebSocket) =>
queue.push(data)
},
onClose(event) {
remote?.close(event.code, event.reason)
closeRemote()
},
}
}),
Expand Down
9 changes: 8 additions & 1 deletion packages/codeplane/src/server/routes/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ export const GlobalRoutes = lazy(() =>
return c.json({ ok: true as const, method: "reload" as const })
} catch (error) {
log.warn("restart dispose failed, falling back to process exit", { error })
setTimeout(() => process.exit(0), 500)
setTimeout(() => process.exit(0), 3000)
return c.json({ ok: true as const, method: "exit" as const })
}
},
Expand Down Expand Up @@ -681,6 +681,13 @@ export const GlobalRoutes = lazy(() =>
// but the in-process binary is unchanged. Exit so the container's restart
// policy brings us back on the new binary. Delay so the response flushes.
if (restart) {
GlobalBus.emit("event", {
directory: "global",
payload: {
type: GlobalDisposedEvent.type,
properties: {},
},
})
setTimeout(() => process.exit(0), 3000)
}
return c.json({
Expand Down
20 changes: 15 additions & 5 deletions packages/codeplane/src/server/routes/instance/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,12 +448,18 @@ export const SessionRoutes = lazy(() =>
// requeue a job the user just stopped.
const queue = yield* PromptQueue.Service
yield* queue.cancelSession(sessionID).pipe(Effect.catch(() => Effect.succeed(0)))
yield* svc.cancel(sessionID).pipe(Effect.catch(() => Effect.void))
yield* svc.cancel(sessionID)
const todo = yield* Todo.Service
yield* todo.update({ sessionID, todos: [] }).pipe(Effect.catch(() => Effect.void))
yield* svc.recordError({ sessionID, error: aborted }).pipe(Effect.catch(() => Effect.void))
yield* todo.update({ sessionID, todos: [] }).pipe(
Effect.catch((e) => Effect.sync(() => log.warn("abort todo cleanup failed", { sessionID, error: e }))),
)
yield* svc.recordError({ sessionID, error: aborted }).pipe(
Effect.catch((e) => Effect.sync(() => log.warn("abort recordError failed", { sessionID, error: e }))),
)
const status = yield* SessionStatus.Service
yield* status.set(sessionID, { type: "idle" }).pipe(Effect.catch(() => Effect.void))
yield* status.set(sessionID, { type: "idle" }).pipe(
Effect.catch((e) => Effect.sync(() => log.warn("abort status set failed", { sessionID, error: e }))),
)
return true
}),
)
Expand Down Expand Up @@ -911,7 +917,11 @@ export const SessionRoutes = lazy(() =>
svc.prompt({ ...body, sessionID } as unknown as SessionPrompt.PromptInput),
),
)
void stream.write(JSON.stringify(msg))
try {
await stream.write(JSON.stringify(msg))
} catch {
// Client already disconnected; the prompt was processed server-side.
}
})
},
)
Expand Down
2 changes: 2 additions & 0 deletions packages/codeplane/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,11 @@ export async function listen(opts: {
const server = await built.runtime.listen(opts)
await cronSchedulerRuntime.runPromise((svc) => svc.start()).catch((err) => {
log.error("failed to start cron scheduler", { error: err instanceof Error ? err.message : String(err) })
throw err
})
await promptQueueWorkerRuntime.runPromise((svc) => svc.start()).catch((err) => {
log.error("failed to start prompt queue worker", { error: err instanceof Error ? err.message : String(err) })
throw err
})
UpdateChecker.start()

Expand Down
6 changes: 5 additions & 1 deletion packages/codeplane/src/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -771,7 +771,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
])

event.on(TuiEvent.CommandExecute.type, (evt) => {
command.trigger(evt.properties.command)
try {
command.trigger(evt.properties.command)
} catch (error) {
toast.show({ variant: "error", message: errorMessage(error), duration: 5000 })
}
})

event.on(TuiEvent.ToastShow.type, (evt) => {
Expand Down
69 changes: 44 additions & 25 deletions packages/codeplane/src/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1292,15 +1292,22 @@ export function Prompt(props: PromptProps) {
// send this submission later (queue drain) or now (immediate / steer).
const dispatch = async () => {
if (currentMode === "shell") {
void sdk.client.session.shell({
sessionID: sessionID!,
agent: agent.name,
model: {
providerID: selectedModel.providerID,
modelID: selectedModel.modelID,
},
command: inputText,
})
try {
await sdk.client.session.shell({
sessionID: sessionID!,
agent: agent.name,
model: {
providerID: selectedModel.providerID,
modelID: selectedModel.modelID,
},
command: inputText,
})
} catch (error) {
toast.show({
message: error instanceof Error ? error.message : "Failed to run shell command",
variant: "error",
})
}
return
}
if (isCustomCommand) {
Expand All @@ -1309,21 +1316,28 @@ export function Prompt(props: PromptProps) {
const [command, ...firstLineArgs] = firstLine.split(" ")
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
void sdk.client.session.command({
sessionID: sessionID!,
command: command.slice(1),
arguments: args,
agent: agent.name,
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
messageID,
variant,
parts: nonTextParts
.filter((x) => x.type === "file")
.map((x) => ({
id: PartID.ascending(),
...x,
})),
})
try {
await sdk.client.session.command({
sessionID: sessionID!,
command: command.slice(1),
arguments: args,
agent: agent.name,
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
messageID,
variant,
parts: nonTextParts
.filter((x) => x.type === "file")
.map((x) => ({
id: PartID.ascending(),
...x,
})),
})
} catch (error) {
toast.show({
message: error instanceof Error ? error.message : "Failed to run custom command",
variant: "error",
})
}
return
}
// Always go through the server's persistent queue, even for an idle
Expand Down Expand Up @@ -1360,7 +1374,12 @@ export function Prompt(props: PromptProps) {
// Cheap (one HTTP round-trip), idempotent (reducer dedupes by id).
if (sessionID) void refreshQueue(sessionID)
})
.catch(() => {})
.catch((error) => {
toast.show({
message: error instanceof Error ? error.message : "Failed to submit prompt",
variant: "error",
})
})
lastSubmittedEditorSelectionKey = currentEditorSelectionKey
}

Expand Down
Loading
Loading