From b1ce0608aa32d8027d90e7c24ce2c04694c07f82 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:38:57 +0000 Subject: [PATCH 1/6] feat(permission): let agent runners ask the person mid-turn POST /session/:sessionID/permission { permission, patterns, metadata?, always? } raises the session's permission card from outside a tool call and blocks until the person answers, returning { reply: "once" | "always" | "reject", message? }. The session's own rules and earlier "always" answers apply, so an allowed pattern returns "always" without a card. Claude Code's permission prompt tool and Codex's approval requests point here, so "ask" mode works for runner turns. --- .../instance/httpapi/groups/permission.ts | 30 ++++++++++ .../instance/httpapi/handlers/permission.ts | 56 ++++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/packages/redrob/src/server/routes/instance/httpapi/groups/permission.ts b/packages/redrob/src/server/routes/instance/httpapi/groups/permission.ts index a4f6d17821..41cfeeaa30 100644 --- a/packages/redrob/src/server/routes/instance/httpapi/groups/permission.ts +++ b/packages/redrob/src/server/routes/instance/httpapi/groups/permission.ts @@ -2,6 +2,7 @@ import { PermissionV1 } from "@redrob-code/core/v1/permission" import { Permission } from "@/permission" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { SessionID } from "@/session/schema" import { PermissionNotFoundError } from "../errors" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" @@ -14,6 +15,21 @@ const ReplyPayload = Schema.Struct({ message: Schema.optional(Schema.String), }) +/** + * A permission raised from outside a local tool call, by an agent runner (Claude Code, Codex) that + * runs its own tools. See docs/RUNNER-PROTOCOL.md. + */ +export const RunnerAskPayload = Schema.Struct({ + permission: Schema.String, + patterns: Schema.Array(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + always: Schema.optional(Schema.Array(Schema.String)), +}) +export const RunnerAskResult = Schema.Struct({ + reply: PermissionV1.Reply, + message: Schema.optional(Schema.String), +}) + export const PermissionApi = HttpApi.make("permission") .add( HttpApiGroup.make("permission") @@ -41,6 +57,20 @@ export const PermissionApi = HttpApi.make("permission") description: "Approve or deny a permission request from the AI assistant.", }), ), + HttpApiEndpoint.post("ask", "/session/:sessionID/permission", { + params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, + payload: RunnerAskPayload, + success: described(RunnerAskResult, "The person's answer"), + error: [HttpApiError.BadRequest], + }).annotateMerge( + OpenApi.annotations({ + identifier: "permission.ask", + summary: "Ask the person for a permission", + description: + "Raise a permission request for a session from outside a tool call and wait for the answer. Used by agent runners that run their own tools.", + }), + ), ) .annotateMerge( OpenApi.annotations({ diff --git a/packages/redrob/src/server/routes/instance/httpapi/handlers/permission.ts b/packages/redrob/src/server/routes/instance/httpapi/handlers/permission.ts index c6b820a168..f7d353b0e8 100644 --- a/packages/redrob/src/server/routes/instance/httpapi/handlers/permission.ts +++ b/packages/redrob/src/server/routes/instance/httpapi/handlers/permission.ts @@ -1,13 +1,20 @@ import { PermissionV1 } from "@redrob-code/core/v1/permission" +import { EventV2 } from "@redrob-code/core/event" +import { EventV2Bridge } from "@/event-v2-bridge" import { Permission } from "@/permission" +import { Session } from "@/session/session" +import { SessionID } from "@/session/schema" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { PermissionNotFoundError } from "../errors" +import { RunnerAskPayload, RunnerAskResult } from "../groups/permission" export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permission", (handlers) => Effect.gen(function* () { const svc = yield* Permission.Service + const session = yield* Session.Service + const events = yield* EventV2Bridge.Service const list = Effect.fn("PermissionHttpApi.list")(function* () { return yield* svc.list() @@ -36,6 +43,53 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permiss return true }) - return handlers.handle("list", list).handle("reply", reply) + /* + * Raises the same `permission.asked` a local tool would, for a runner that ran the tool itself, and + * blocks until the person answers. The session's own rules and earlier "always" answers apply, so a + * pattern the person already allowed comes back "always" without a card. A request the runner + * drops (its HTTP call closes) is interrupted here, which removes it from the pending list. + */ + const ask = Effect.fn("PermissionHttpApi.ask")(function* (ctx: { + params: { sessionID: SessionID } + payload: typeof RunnerAskPayload.Type + }) { + const info = yield* session.get(ctx.params.sessionID).pipe(Effect.orElseSucceed(() => undefined)) + const id = PermissionV1.ID.ascending() + let answered: PermissionV1.Reply | undefined + const unsubscribe = yield* events.listen((event) => { + if (event.type !== Permission.Event.Replied.type) return Effect.void + const data = event.data as EventV2.Data + if (data.requestID === id) answered = data.reply + return Effect.void + }) + return yield* svc + .ask({ + id, + sessionID: ctx.params.sessionID, + permission: ctx.payload.permission, + patterns: [...ctx.payload.patterns], + metadata: { ...ctx.payload.metadata, runner: true }, + always: [...(ctx.payload.always ?? ctx.payload.patterns)], + ruleset: info?.permission ?? [], + }) + .pipe( + Effect.map((): typeof RunnerAskResult.Type => ({ reply: answered ?? "always" })), + Effect.catchTag("PermissionCorrectedError", (error) => + Effect.succeed({ reply: "reject", message: error.feedback }), + ), + Effect.catchTag("PermissionDeniedError", () => + Effect.succeed({ + reply: "reject", + message: "Denied by the session's permission rules.", + }), + ), + Effect.catchTag("PermissionRejectedError", () => + Effect.succeed({ reply: "reject" }), + ), + Effect.ensuring(unsubscribe), + ) + }) + + return handlers.handle("list", list).handle("reply", reply).handle("ask", ask) }), ) From 52f8fb97c69b6d265a59245a0977ce82cf249093 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:40:22 +0000 Subject: [PATCH 2/6] feat(session): runner turns get their folder and skip the title run - Providers marked options.agentRunner or options.providerExecutedTools get the session's folder as x-redrob-directory (encodeURIComponent), so a turn started from a schedule, the desk relay or the phone runs the CLI in the right folder. Other providers never see local paths. - A chat on an options.agentRunner provider is titled after its first line instead of spending a full CLI run on a title. - Runner permission requests keep the runner's own metadata. --- .../instance/httpapi/handlers/permission.ts | 2 +- packages/redrob/src/session/llm/request.ts | 12 ++++++++++++ packages/redrob/src/session/prompt.ts | 19 +++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/redrob/src/server/routes/instance/httpapi/handlers/permission.ts b/packages/redrob/src/server/routes/instance/httpapi/handlers/permission.ts index f7d353b0e8..37e985609b 100644 --- a/packages/redrob/src/server/routes/instance/httpapi/handlers/permission.ts +++ b/packages/redrob/src/server/routes/instance/httpapi/handlers/permission.ts @@ -68,7 +68,7 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permiss sessionID: ctx.params.sessionID, permission: ctx.payload.permission, patterns: [...ctx.payload.patterns], - metadata: { ...ctx.payload.metadata, runner: true }, + metadata: { runner: true, ...ctx.payload.metadata }, always: [...(ctx.payload.always ?? ctx.payload.patterns)], ruleset: info?.permission ?? [], }) diff --git a/packages/redrob/src/session/llm/request.ts b/packages/redrob/src/session/llm/request.ts index aec193ba1d..427f4ec34d 100644 --- a/packages/redrob/src/session/llm/request.ts +++ b/packages/redrob/src/session/llm/request.ts @@ -184,6 +184,9 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre const redrobProjectID = input.model.providerID.startsWith("redrob") ? (yield* InstanceState.context).project.id : undefined + // An agent runner (Claude Code, Codex behind the desktop's local shim) runs its own tools, so it + // needs the folder the session works in. Only runners get it: other providers never see local paths. + const runnerDirectory = isAgentRunner(input.provider) ? (yield* InstanceState.context).directory : undefined return { system, @@ -204,6 +207,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre "x-session-affinity": input.sessionID, "X-Session-Id": input.sessionID, "User-Agent": USER_AGENT, + ...(runnerDirectory ? { "x-redrob-directory": encodeURIComponent(runnerDirectory) } : {}), }), ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}), ...input.model.headers, @@ -220,6 +224,14 @@ function resolveTools(input: Pick input.user.tools?.[k] !== false && !disabled.has(k)) } +/** + * A provider whose model is an agent CLI that runs its own tools (config `options.agentRunner` or + * `options.providerExecutedTools`). See docs/RUNNER-PROTOCOL.md. + */ +export function isAgentRunner(provider: Pick): boolean { + return provider.options?.agentRunner === true || provider.options?.providerExecutedTools === true +} + export function hasToolCalls(messages: ModelMessage[]): boolean { for (const msg of messages) { if (!Array.isArray(msg.content)) continue diff --git a/packages/redrob/src/session/prompt.ts b/packages/redrob/src/session/prompt.ts index 4aca4e586b..e64aa7efec 100644 --- a/packages/redrob/src/session/prompt.ts +++ b/packages/redrob/src/session/prompt.ts @@ -252,6 +252,25 @@ const layer = Layer.effect( ? yield* provider.getModel(ag.model.providerID, ag.model.modelID) : ((yield* provider.getSmallModel(input.providerID)) ?? (yield* provider.getModel(input.providerID, input.modelID))) + // An agent runner (Claude Code, Codex) would spend a full CLI run of the person's plan on a + // title. Name the chat after its first line instead. + const runner = yield* provider.getProvider(mdl.providerID).pipe( + Effect.map((info) => info.options?.agentRunner === true), + Effect.orElseSucceed(() => false), + ) + if (runner) { + const line = firstUser.parts + .flatMap((p) => (p.type === "text" && !p.synthetic ? [p.text] : p.type === "subtask" ? [p.prompt] : [])) + .join("\n") + .split("\n") + .map((l) => l.trim()) + .find((l) => l.length > 0) + if (!line) return + yield* sessions + .setTitle({ sessionID: input.session.id, title: line.length > 100 ? line.substring(0, 97) + "..." : line }) + .pipe(Effect.catchCause((cause) => Effect.logError("failed to set title", { error: Cause.squash(cause) }))) + return + } const msgs = onlySubtasks ? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }] : yield* MessageV2.toModelMessagesEffect(context, mdl) From 566043620a8f75d3e6523bbfa0305b05a4a6db6e Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:41:46 +0000 Subject: [PATCH 3/6] feat(session): store the tools an agent runner ran as tool parts Under provider option providerExecutedTools the stream's choices[0].delta.tool_calls[*] entries with "provider_executed": true, "title" and "result": { output } | { error } are read from the raw chunk and emitted as a provider-executed tool-call + tool-result pair, so the AI SDK runs nothing locally and the processor saves them as completed or errored tool parts of the assistant message. They survive a reload, a fork and a share. The parser's own parts for those ids are dropped, runner calls are left out of the doom-loop check and out of the prompt replayed to the runner, and the native runtime is not used for these providers. --- packages/redrob/src/session/llm.ts | 9 +- .../redrob/src/session/llm/runner-tools.ts | 128 ++++++++++++++++++ packages/redrob/src/session/processor.ts | 2 + 3 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 packages/redrob/src/session/llm/runner-tools.ts diff --git a/packages/redrob/src/session/llm.ts b/packages/redrob/src/session/llm.ts index c4d151be0c..c01c765a71 100644 --- a/packages/redrob/src/session/llm.ts +++ b/packages/redrob/src/session/llm.ts @@ -29,6 +29,7 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer" import { LLMAISDK } from "./llm/ai-sdk" import { LLMNativeRuntime } from "./llm/native-runtime" import { LLMRequestPrep } from "./llm/request" +import { LLMRunnerTools } from "./llm/runner-tools" export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX @@ -223,7 +224,7 @@ const live: Layer.Layer< // Runtime seam: native is an opt-in adapter over @redrob-code/llm. It // either returns a ready LLMEvent stream or a concrete fallback reason. - if (flags.experimentalNativeLlm) { + if (flags.experimentalNativeLlm && item.options?.providerExecutedTools !== true) { const native = LLMNativeRuntime.stream({ model: input.model, provider: item, @@ -275,6 +276,8 @@ const live: Layer.Layer< }) // Default runtime path: AI SDK owns provider execution and tool dispatch; // LLMAISDK.toLLMEvents below normalizes fullStream parts for the processor. + const copilotRaw = input.model.providerID.includes("github-copilot") + const runnerTools = item.options?.providerExecutedTools === true return { type: "ai-sdk" as const, result: streamText({ @@ -292,7 +295,8 @@ const live: Layer.Layer< ) }, // Copilot returns the authoritative billed amount only in provider-specific response fields. - includeRawChunks: input.model.providerID.includes("github-copilot"), + // Agent runners report the tools they ran in fields only the raw chunk keeps. + includeRawChunks: copilotRaw || runnerTools, async experimental_repairToolCall(failed) { const lower = failed.toolCall.toolName.toLowerCase() if (lower !== failed.toolCall.toolName && prepared.tools[lower]) { @@ -339,6 +343,7 @@ const live: Layer.Layer< return args.params }, }, + ...(runnerTools ? [LLMRunnerTools.providerExecutedTools(copilotRaw)] : []), ], }), experimental_telemetry: { diff --git a/packages/redrob/src/session/llm/runner-tools.ts b/packages/redrob/src/session/llm/runner-tools.ts new file mode 100644 index 0000000000..63bfe45814 --- /dev/null +++ b/packages/redrob/src/session/llm/runner-tools.ts @@ -0,0 +1,128 @@ +import type { LanguageModelV3Middleware, LanguageModelV3StreamPart } from "@ai-sdk/provider" + +/** + * Tool calls an agent runner already ran (Claude Code, Codex behind the desktop's local shim). + * + * Under provider option `providerExecutedTools: true` the provider streams each finished CLI tool call + * as an OpenAI `choices[0].delta.tool_calls[*]` entry carrying `"provider_executed": true`, an optional + * `"title"`, and `"result": { "output": string } | { "error": string }`. The openai-compatible parser + * drops those extra fields and would hand the call to a local tool, so this middleware reads them from + * the raw chunk instead, swallows the parser's own parts for those ids, and emits a provider-executed + * `tool-call` + `tool-result` pair. The AI SDK then runs nothing locally and the session processor + * stores them as completed (or errored) `tool` parts. Shape: docs/RUNNER-PROTOCOL.md. + * + * Needs `includeRawChunks: true` on the stream call; `forwardRaw` says whether the caller wanted the raw + * parts itself. + */ +export function providerExecutedTools(forwardRaw: boolean): LanguageModelV3Middleware { + return { + specificationVersion: "v3", + // The runner keeps its own record of the tools it ran; replaying them as OpenAI tool_calls with no + // tool messages after them is a malformed history, so they leave the prompt. + async transformParams({ params }) { + return { + ...params, + prompt: params.prompt.map((message) => + message.role === "assistant" + ? { + ...message, + content: message.content.filter( + (part) => !(part.type === "tool-call" && part.providerExecuted) && part.type !== "tool-result", + ), + } + : message, + ), + } + }, + async wrapStream({ doStream }) { + const { stream, ...rest } = await doStream() + const ran = new Set() + return { + ...rest, + stream: stream.pipeThrough( + new TransformStream({ + transform(part, controller) { + if (part.type === "raw") { + for (const call of runnerCalls(part.rawValue)) { + if (ran.has(call.id)) continue + ran.add(call.id) + controller.enqueue({ + type: "tool-call", + toolCallId: call.id, + toolName: call.name, + input: call.input, + providerExecuted: true, + dynamic: true, + }) + controller.enqueue( + call.error !== undefined + ? { + type: "tool-result", + toolCallId: call.id, + toolName: call.name, + result: call.error, + isError: true, + dynamic: true, + } + : { + type: "tool-result", + toolCallId: call.id, + toolName: call.name, + result: { title: call.title ?? call.name, output: call.output ?? "", metadata: {} }, + dynamic: true, + }, + ) + } + if (forwardRaw) controller.enqueue(part) + return + } + if ( + (part.type === "tool-input-start" || part.type === "tool-input-delta" || part.type === "tool-input-end") && + ran.has(part.id) + ) + return + if (part.type === "tool-call" && ran.has(part.toolCallId)) return + controller.enqueue(part) + }, + }), + ), + } + }, + } +} + +type RunnerCall = { id: string; name: string; input: string; title?: string; output?: string; error?: string } + +function runnerCalls(raw: unknown): RunnerCall[] { + if (!isRecord(raw) || !Array.isArray(raw.choices)) return [] + const calls: RunnerCall[] = [] + for (const choice of raw.choices) { + if (!isRecord(choice) || !isRecord(choice.delta) || !Array.isArray(choice.delta.tool_calls)) continue + for (const item of choice.delta.tool_calls) { + if (!isRecord(item) || item.provider_executed !== true || typeof item.id !== "string") continue + const fn = isRecord(item.function) ? item.function : {} + const args = fn.arguments + const result = isRecord(item.result) ? item.result : {} + calls.push({ + id: item.id, + name: typeof fn.name === "string" && fn.name ? fn.name : "tool", + input: typeof args === "string" ? args : args === undefined ? "" : JSON.stringify(args), + title: typeof item.title === "string" ? item.title : undefined, + output: text(result.output), + error: result.error === undefined || result.error === null ? undefined : (text(result.error) ?? "Error"), + }) + } + } + return calls +} + +function text(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined + return typeof value === "string" ? value : JSON.stringify(value) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +export * as LLMRunnerTools from "./runner-tools" diff --git a/packages/redrob/src/session/processor.ts b/packages/redrob/src/session/processor.ts index 7a43dfaf17..1fea185bf1 100644 --- a/packages/redrob/src/session/processor.ts +++ b/packages/redrob/src/session/processor.ts @@ -349,6 +349,8 @@ const layer = Layer.effect( ? { ...value.providerMetadata, providerExecuted: true } : value.providerMetadata, })) + // A provider that already ran the call (an agent runner) is not looping on our tools. + if (value.providerExecuted) return const parts = yield* MessageV2.parts(ctx.assistantMessage.id).pipe( Effect.provideService(Database.Service, database), From fffaa7951b2ed0d0942c30c4e70dcc5993fadba7 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:44:01 +0000 Subject: [PATCH 4/6] fix(plugin): plugins' $ no longer opens a console window on Windows Bun.$ pops a console window per command on Windows. There plugins now get a $ with the same tagged-template API (await $`cmd`, .text(), .json(), .lines(), .quiet(), .nothrow(), .throws(), .cwd(), .env(), $.escape/cwd/env/nothrow) that runs through the shared spawn helper with windowsHide: in Git Bash when Git for Windows is installed, else cmd.exe, with interpolations quoted for that shell. Output is buffered and not echoed; .stdin is not available. Other platforms keep Bun.$. --- packages/redrob/src/plugin/index.ts | 4 +- packages/redrob/src/plugin/shell.ts | 177 ++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 packages/redrob/src/plugin/shell.ts diff --git a/packages/redrob/src/plugin/index.ts b/packages/redrob/src/plugin/index.ts index e5865fbd81..cabdb87735 100644 --- a/packages/redrob/src/plugin/index.ts +++ b/packages/redrob/src/plugin/index.ts @@ -7,6 +7,7 @@ import type { WorkspaceAdapter as PluginWorkspaceAdapter, } from "@redrob-code/plugin" import { Config } from "@/config/config" +import { pluginShell } from "./shell" import { createRedrobClient } from "@redrob-labs/sdk" import { ServerAuth } from "@/server/auth" import { Session } from "@/session/session" @@ -156,8 +157,7 @@ const layer = Layer.effect( get serverUrl(): URL { return Server.url ?? new URL("http://localhost:4096") }, - // @ts-expect-error - $: typeof Bun === "undefined" ? undefined : Bun.$, + $: pluginShell(), } for (const plugin of flags.disableDefaultPlugins ? [] : internalPlugins()) { diff --git a/packages/redrob/src/plugin/shell.ts b/packages/redrob/src/plugin/shell.ts new file mode 100644 index 0000000000..6021f2cde4 --- /dev/null +++ b/packages/redrob/src/plugin/shell.ts @@ -0,0 +1,177 @@ +import type { PluginInput } from "@redrob-code/plugin" +import { Shell } from "@redrob-code/core/shell" +import { Process } from "@/util/process" + +type PluginShell = PluginInput["$"] +type ShellPromise = ReturnType +type ShellOutput = Awaited +type Expression = Parameters[1] + +/** + * The `$` handed to plugins. + * + * On Windows `Bun.$` opens a console window for every command it runs, which flashes on top of the + * desktop app each time a plugin checks git or runs a tool. There the plugins get this shell instead: + * the same tagged-template API (`await $\`cmd\``, `.text()`, `.json()`, `.lines()`, `.quiet()`, + * `.nothrow()`, `.throws()`, `.cwd()`, `.env()`, `$.escape()`, `$.cwd()`, `$.env()`, `$.nothrow()`), + * run through the shared spawn helper with `windowsHide`. The command runs in Git Bash when Git for + * Windows is installed (the closest to Bun's bash-like syntax), otherwise in cmd.exe. Interpolated values + * are quoted for that shell. Output is buffered, never echoed, and `.stdin` is not available. + * + * Everywhere else plugins keep `Bun.$` itself. + */ +export function pluginShell(): PluginShell | undefined { + if (process.platform === "win32") return make({ throws: true }) + return typeof Bun === "undefined" ? undefined : (Bun.$ as unknown as PluginShell) +} + +type Defaults = { + cwd?: string + env?: Record + throws: boolean +} + +type Target = { file: string; posix: boolean } + +let target: Target | undefined +function shellTarget(): Target { + if (target) return target + const bash = Shell.gitbash() + target = bash ? { file: bash, posix: true } : { file: process.env.ComSpec || "cmd.exe", posix: false } + return target +} + +function quote(value: string): string { + if (value !== "" && /^[\w@%+=:,./\\-]+$/.test(value)) return value + if (shellTarget().posix) return `'${value.replace(/'/g, `'\\''`)}'` + return `"${value.replace(/"/g, '""')}"` +} + +function render(strings: TemplateStringsArray, expressions: Expression[]): string { + let out = strings[0] ?? "" + for (let i = 0; i < expressions.length; i++) { + out += expression(expressions[i]) + (strings[i + 1] ?? "") + } + return out +} + +function expression(value: unknown): string { + if (Array.isArray(value)) return value.map(expression).join(" ") + if (typeof value === "object" && value !== null && "raw" in value && typeof value.raw === "string") return value.raw + if (typeof ReadableStream !== "undefined" && value instanceof ReadableStream) { + throw new Error("The plugin shell on Windows does not take a stream as input") + } + return quote(String(value)) +} + +function make(defaults: Defaults): PluginShell { + const shell = ((strings: TemplateStringsArray, ...expressions: Expression[]) => + command(render(strings, expressions), defaults)) as PluginShell + shell.braces = (pattern: string) => { + if (typeof Bun !== "undefined") return Bun.$.braces(pattern) + return [pattern] + } + shell.escape = quote + shell.env = (env?: Record) => make({ ...defaults, env }) + shell.cwd = (cwd?: string) => make({ ...defaults, cwd }) + shell.nothrow = () => make({ ...defaults, throws: false }) + shell.throws = (throws: boolean) => make({ ...defaults, throws }) + return shell +} + +function command(text: string, defaults: Defaults): ShellPromise { + const state = { ...defaults } + let started: Promise | undefined + const start = () => (started ??= execute(text, state)) + const self = { + get stdin(): WritableStream { + throw new Error("The plugin shell on Windows has no stdin") + }, + cwd(cwd: string) { + state.cwd = cwd + return self + }, + env(env: Record | undefined) { + state.env = env + return self + }, + quiet() { + return self + }, + nothrow() { + state.throws = false + return self + }, + throws(throws: boolean) { + state.throws = throws + return self + }, + async *lines() { + const output = await start() + for (const line of output.text().split(/\r?\n/)) { + if (line) yield line + } + }, + text(encoding?: BufferEncoding) { + return start().then((output) => output.text(encoding)) + }, + json() { + return start().then((output) => output.json()) + }, + arrayBuffer() { + return start().then((output) => output.arrayBuffer()) + }, + blob() { + return start().then((output) => output.blob()) + }, + then( + onfulfilled?: ((value: ShellOutput) => A | PromiseLike) | null, + onrejected?: ((reason: any) => B | PromiseLike) | null, + ) { + return start().then(onfulfilled, onrejected) + }, + catch(onrejected?: ((reason: any) => B | PromiseLike) | null) { + return start().catch(onrejected) + }, + finally(onfinally?: (() => void) | null) { + return start().finally(onfinally) + }, + [Symbol.toStringTag]: "ShellPromise", + } + // Bun.$ runs a command even when nobody awaits it; modifiers chained in the same tick still apply. + queueMicrotask(() => void start().catch(() => undefined)) + return self as unknown as ShellPromise +} + +async function execute(text: string, state: Defaults): Promise { + const shell = shellTarget() + const env = state.env + ? Object.fromEntries(Object.entries(state.env).filter((entry): entry is [string, string] => entry[1] !== undefined)) + : undefined + const result = shell.posix + ? await Process.run([shell.file, "-c", text], { cwd: state.cwd, env, nothrow: true }) + : await Process.run([text], { cwd: state.cwd, env, shell: true, nothrow: true }) + const output = toOutput(result) + if (result.code !== 0 && state.throws) { + const error = new Error( + `Failed with exit code ${result.code}${result.stderr.length ? `\n${result.stderr.toString().trim()}` : ""}`, + ) + error.name = "ShellError" + throw Object.assign(error, output) + } + return output +} + +function toOutput(result: Process.Result): ShellOutput { + const stdout = result.stdout + return { + stdout, + stderr: result.stderr, + exitCode: result.code, + text: (encoding?: BufferEncoding) => stdout.toString(encoding), + json: () => JSON.parse(stdout.toString()), + arrayBuffer: () => stdout.buffer.slice(stdout.byteOffset, stdout.byteOffset + stdout.byteLength) as ArrayBuffer, + bytes: () => new Uint8Array(stdout), + blob: () => new Blob([new Uint8Array(stdout)]), + } +} From dcb96ca4bf457094045421265ee90c1532fbd73d Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:45:08 +0000 Subject: [PATCH 5/6] docs: agent runner protocol docs/RUNNER-PROTOCOL.md writes down the shapes the engine and the desktop's Claude Code / Codex shim share: provider options providerExecutedTools and agentRunner, the x-redrob-directory header, provider-executed tool_calls in the stream, POST /session/:id/permission, and how titles and compaction behave. They match redrob-desktop HARNESS-NEEDS.md unchanged. --- docs/RUNNER-PROTOCOL.md | 102 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 docs/RUNNER-PROTOCOL.md diff --git a/docs/RUNNER-PROTOCOL.md b/docs/RUNNER-PROTOCOL.md new file mode 100644 index 0000000000..aec6ba67c4 --- /dev/null +++ b/docs/RUNNER-PROTOCOL.md @@ -0,0 +1,102 @@ +# Agent runner protocol + +How the engine talks to an agent runner: a local OpenAI-compatible provider whose "model" is a coding +CLI (Claude Code, Codex) that runs its own tools. The desktop app's shim +(redrob-desktop `apps/desktop/node-host/harness.mjs`) is the one runner today. The shapes below are the +ones redrob-desktop `apps/desktop/src/v2/features/HARNESS-NEEDS.md` uses; nothing was changed from it. + +## Final shapes + +### Provider options + +```jsonc +"provider": { + "claude-code": { + "npm": "@ai-sdk/openai-compatible", + "options": { + "baseURL": "http://127.0.0.1:/v1", + "providerExecutedTools": true, // read runner tool calls from the stream (below) + "agentRunner": true // no title/summary run on this model + } + } +} +``` + +Either flag marks the provider as a runner for `x-redrob-directory`. + +### Request headers (engine to runner) + +| Header | Value | +| --- | --- | +| `x-session-affinity`, `X-Session-Id` | engine session id | +| `x-redrob-directory` | the session's folder, `encodeURIComponent`-encoded. Sent only to runner providers. | +| `x-parent-session-id` | parent session id, for a subagent session | + +### Tools the runner ran (runner to engine, in the stream) + +One `tool_calls` delta per finished CLI tool call, in a normal chat-completions chunk: + +```json +{ + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": 0, + "id": "runner_", + "type": "function", + "function": { "name": "bash", "arguments": "{\"command\":\"ls\"}" }, + "provider_executed": true, + "title": "ls", + "result": { "output": "a\nb\n" } + }] + } + }] +} +``` + +- `result` is `{ "output": string }` or `{ "error": string }`. +- `function.arguments` is a JSON string (an object is accepted too). +- Each call needs its own `index` and a unique `id`; send the whole call in one delta. +- Use `finish_reason: "stop"`, not `"tool_calls"`. +- The engine stores each as a `tool` part of the assistant message (`state.status` `completed` or + `error`, `metadata.providerExecuted: true`) and runs nothing locally. A call shows once it has + finished. +- Engine side: `packages/redrob/src/session/llm/runner-tools.ts`. + +Not replayed: on later turns the runner's own calls are left out of the prompt the engine sends back, +so the history has no `tool_calls` without tool messages. + +### Approval mid-turn (runner to engine) + +``` +POST /session/:sessionID/permission +{ "permission": "bash", "patterns": ["git push"], "metadata": { ... }, "always": ["git push"] } + +200 { "reply": "once" | "always" | "reject", "message"?: string } +``` + +- The engine finds the session's folder from the id in the path; no directory header is needed. +- Blocks until the person answers the session's permission card. +- `metadata` and `always` are optional. `always` defaults to `patterns`: the patterns an "always" + answer allows for the rest of the session. +- The session's own permission rules and earlier "always" answers apply: an allowed pattern returns + `"always"` at once, a denied one returns `"reject"` with a `message`. +- A reject with feedback typed by the person returns it as `message`. +- Closing the HTTP request drops the pending request. +- The request carries `metadata.runner: true` unless the runner sets `metadata.runner` itself. +- Permission names are the engine's tool names (`bash`, `edit`, `write`, `read`, `webfetch`, ...); + patterns are the command or the file paths. + +### Titles and summaries + +- A chat on an `agentRunner` provider is titled after the first line of its first message; no model + call is made for it (unless the `title` agent names its own model). +- Compaction is not skipped. It fires automatically only when the runner reports token usage that + crosses the threshold; a runner that reports none is compacted only when the person asks. That + request arrives without tools, and the shim answers it as a read-only side request. + +### Not done + +- `x-redrob-turn` (telling a retry from a new turn) is not sent; the shim does not need it for + correctness. From d192109f2258ee233038ad7b3db6c9ce56fc8aa3 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:49:42 +0000 Subject: [PATCH 6/6] fix(permission): a request nobody answered closes its card When the asker goes away (a cancelled turn, or an agent runner that drops its HTTP call) the pending request is now answered "reject" on the bus, so clients take the card down. Plugins' $ keeps the @ts-expect-error for the undefined-outside-Bun case. SDK regenerated for the runner permission route. --- packages/redrob/src/permission/index.ts | 6 ++- packages/redrob/src/plugin/index.ts | 1 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 49 +++++++++++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 40 ++++++++++++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) diff --git a/packages/redrob/src/permission/index.ts b/packages/redrob/src/permission/index.ts index c0efc85840..8e6f419ec4 100644 --- a/packages/redrob/src/permission/index.ts +++ b/packages/redrob/src/permission/index.ts @@ -100,8 +100,12 @@ const layer = Layer.effect( yield* events.publish(Event.Asked, info) return yield* Effect.ensuring( Deferred.await(deferred), - Effect.sync(() => { + // Still pending here means nobody answered: the asker went away (a cancelled turn, or a runner + // that dropped its HTTP call). Tell the clients, so an open card does not linger. + Effect.suspend(() => { + if (!pending.has(id)) return Effect.void pending.delete(id) + return events.publish(Event.Replied, { sessionID: info.sessionID, requestID: id, reply: "reject" }).pipe(Effect.ignore) }), ) }) diff --git a/packages/redrob/src/plugin/index.ts b/packages/redrob/src/plugin/index.ts index cabdb87735..1bb9fcaf3f 100644 --- a/packages/redrob/src/plugin/index.ts +++ b/packages/redrob/src/plugin/index.ts @@ -157,6 +157,7 @@ const layer = Layer.effect( get serverUrl(): URL { return Server.url ?? new URL("http://localhost:4096") }, + // @ts-expect-error: undefined outside Bun, as before $: pluginShell(), } diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 6333bb0c9e..e0e6e83989 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -138,6 +138,8 @@ import type { PartUpdateResponses, PathGetErrors, PathGetResponses, + PermissionAskErrors, + PermissionAskResponses, PermissionListErrors, PermissionListResponses, PermissionReplyErrors, @@ -3780,6 +3782,53 @@ export class Permission extends HeyApiClient { }) } + /** + * Ask the person for a permission + * + * Raise a permission request for a session from outside a tool call and wait for the answer. Used by agent runners that run their own tools. + */ + public ask( + parameters: { + sessionID: string + directory?: string + workspace?: string + permission?: string + patterns?: Array + metadata?: { + [key: string]: unknown + } + always?: Array + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "permission" }, + { in: "body", key: "patterns" }, + { in: "body", key: "metadata" }, + { in: "body", key: "always" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/permission", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Respond to permission * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 14f344d1e6..d05cc7da8b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -10580,6 +10580,46 @@ export type PermissionReplyResponses = { export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses] +export type PermissionAskData = { + body?: { + permission: string + patterns: Array + metadata?: { + [key: string]: unknown + } + always?: Array + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/permission" +} + +export type PermissionAskErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type PermissionAskError = PermissionAskErrors[keyof PermissionAskErrors] + +export type PermissionAskResponses = { + /** + * The person's answer + */ + 200: { + reply: "once" | "always" | "reject" + message?: string + } +} + +export type PermissionAskResponse = PermissionAskResponses[keyof PermissionAskResponses] + export type ProviderListData = { body?: never path?: never