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 all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
31 changes: 31 additions & 0 deletions packages/codeplane/src/pty/node-pty-shim.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
declare module "@lydell/node-pty" {
export function spawn(
file: string,
args: string[] | string,
options: Record<string, any>,
): IPty

export interface IPty {
readonly pid: number
readonly cols: number
readonly rows: number
readonly process: string
handleFlowControl: boolean
readonly onData: IEvent<string>
readonly onExit: IEvent<{ exitCode: number; signal?: number }>
resize(columns: number, rows: number): void
clear(): void
write(data: string | Buffer): void
kill(signal?: string): void
pause(): void
resume(): void
}

export interface IEvent<T> {
(listener: (e: T) => any): IDisposable
}

export interface IDisposable {
dispose(): void
}
}
1 change: 0 additions & 1 deletion packages/codeplane/src/pty/pty.node.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/** @ts-expect-error */
import * as pty from "@lydell/node-pty"
import type { Opts, Proc } from "./pty"

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 @@ -224,7 +224,7 @@
const { theme, mode, setMode } = themeState
const sync = useSync()
const exit = useExit()
const promptRef = usePromptRef()

Check warning on line 227 in packages/codeplane/src/tui/app.tsx

View workflow job for this annotation

GitHub Actions / lint

eslint(no-unused-vars)

Variable 'promptRef' is declared but never used. Unused variables should start with a '_'.
const routes: RouteMap = new Map()
const [routeRev, setRouteRev] = createSignal(0)
const routeView = (name: string) => {
Expand Down Expand Up @@ -771,7 +771,11 @@
])

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
Loading
Loading