diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 341c7e2f49..2f8f00526f 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -735,7 +735,7 @@ export type Endpoint20_1Input = { readonly location?: Endpoint20_1Request["query"]["location"] readonly command: Endpoint20_1Request["payload"]["command"] readonly cwd?: Endpoint20_1Request["payload"]["cwd"] - readonly timeout?: Endpoint20_1Request["payload"]["timeout"] + readonly timeout: Endpoint20_1Request["payload"]["timeout"] readonly metadata?: Endpoint20_1Request["payload"]["metadata"] } export type Endpoint20_1Output = EffectValue> @@ -749,28 +749,38 @@ export type Endpoint20_2Input = { export type Endpoint20_2Output = EffectValue> export type ShellGetOperation = (input: Endpoint20_2Input) => Effect.Effect -type Endpoint20_3Request = Parameters[0] +type Endpoint20_3Request = Parameters[0] export type Endpoint20_3Input = { readonly id: Endpoint20_3Request["params"]["id"] readonly location?: Endpoint20_3Request["query"]["location"] - readonly cursor?: Endpoint20_3Request["query"]["cursor"] - readonly limit?: Endpoint20_3Request["query"]["limit"] + readonly timeout: Endpoint20_3Request["payload"]["timeout"] } -export type Endpoint20_3Output = EffectValue> -export type ShellOutputOperation = (input: Endpoint20_3Input) => Effect.Effect +export type Endpoint20_3Output = EffectValue> +export type ShellTimeoutOperation = (input: Endpoint20_3Input) => Effect.Effect -type Endpoint20_4Request = Parameters[0] +type Endpoint20_4Request = Parameters[0] export type Endpoint20_4Input = { readonly id: Endpoint20_4Request["params"]["id"] readonly location?: Endpoint20_4Request["query"]["location"] + readonly cursor?: Endpoint20_4Request["query"]["cursor"] + readonly limit?: Endpoint20_4Request["query"]["limit"] } -export type Endpoint20_4Output = EffectValue> -export type ShellRemoveOperation = (input: Endpoint20_4Input) => Effect.Effect +export type Endpoint20_4Output = EffectValue> +export type ShellOutputOperation = (input: Endpoint20_4Input) => Effect.Effect + +type Endpoint20_5Request = Parameters[0] +export type Endpoint20_5Input = { + readonly id: Endpoint20_5Request["params"]["id"] + readonly location?: Endpoint20_5Request["query"]["location"] +} +export type Endpoint20_5Output = EffectValue> +export type ShellRemoveOperation = (input: Endpoint20_5Input) => Effect.Effect export interface ShellApi { readonly list: ShellListOperation readonly create: ShellCreateOperation readonly get: ShellGetOperation + readonly timeout: ShellTimeoutOperation readonly output: ShellOutputOperation readonly remove: ShellRemoveOperation } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 6acad68663..b138af8f65 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -883,7 +883,7 @@ type Endpoint20_1Input = { readonly location?: Endpoint20_1Request["query"]["location"] readonly command: Endpoint20_1Request["payload"]["command"] readonly cwd?: Endpoint20_1Request["payload"]["cwd"] - readonly timeout?: Endpoint20_1Request["payload"]["timeout"] + readonly timeout: Endpoint20_1Request["payload"]["timeout"] readonly metadata?: Endpoint20_1Request["payload"]["metadata"] } const Endpoint20_1 = (raw: RawClient["server.shell"]) => (input: Endpoint20_1Input) => @@ -902,25 +902,38 @@ const Endpoint20_2 = (raw: RawClient["server.shell"]) => (input: Endpoint20_2Inp Effect.mapError(mapClientError), ) -type Endpoint20_3Request = Parameters[0] +type Endpoint20_3Request = Parameters[0] type Endpoint20_3Input = { readonly id: Endpoint20_3Request["params"]["id"] readonly location?: Endpoint20_3Request["query"]["location"] - readonly cursor?: Endpoint20_3Request["query"]["cursor"] - readonly limit?: Endpoint20_3Request["query"]["limit"] + readonly timeout: Endpoint20_3Request["payload"]["timeout"] } const Endpoint20_3 = (raw: RawClient["server.shell"]) => (input: Endpoint20_3Input) => + raw["shell.timeout"]({ + params: { id: input["id"] }, + query: { location: input["location"] }, + payload: { timeout: input["timeout"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint20_4Request = Parameters[0] +type Endpoint20_4Input = { + readonly id: Endpoint20_4Request["params"]["id"] + readonly location?: Endpoint20_4Request["query"]["location"] + readonly cursor?: Endpoint20_4Request["query"]["cursor"] + readonly limit?: Endpoint20_4Request["query"]["limit"] +} +const Endpoint20_4 = (raw: RawClient["server.shell"]) => (input: Endpoint20_4Input) => raw["shell.output"]({ params: { id: input["id"] }, query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint20_4Request = Parameters[0] -type Endpoint20_4Input = { - readonly id: Endpoint20_4Request["params"]["id"] - readonly location?: Endpoint20_4Request["query"]["location"] +type Endpoint20_5Request = Parameters[0] +type Endpoint20_5Input = { + readonly id: Endpoint20_5Request["params"]["id"] + readonly location?: Endpoint20_5Request["query"]["location"] } -const Endpoint20_4 = (raw: RawClient["server.shell"]) => (input: Endpoint20_4Input) => +const Endpoint20_5 = (raw: RawClient["server.shell"]) => (input: Endpoint20_5Input) => raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) @@ -929,8 +942,9 @@ const adaptGroup20 = (raw: RawClient["server.shell"]) => ({ list: Endpoint20_0(raw), create: Endpoint20_1(raw), get: Endpoint20_2(raw), - output: Endpoint20_3(raw), - remove: Endpoint20_4(raw), + timeout: Endpoint20_3(raw), + output: Endpoint20_4(raw), + remove: Endpoint20_5(raw), }) type Endpoint21_0Request = Parameters[0] diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 82d5e4b048..7e18a0c061 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -151,6 +151,8 @@ import type { ShellCreateOutput, ShellGetInput, ShellGetOutput, + ShellTimeoutInput, + ShellTimeoutOutput, ShellOutputInput, ShellOutputOutput, ShellRemoveInput, @@ -1313,6 +1315,19 @@ export function make(options: ClientOptions) { }, requestOptions, ), + timeout: (input: ShellTimeoutInput, requestOptions?: RequestOptions) => + request( + { + method: "PATCH", + path: `/api/shell/${encodeURIComponent(input.id)}/timeout`, + query: { location: input["location"] }, + body: { timeout: input["timeout"] }, + successStatus: 200, + declaredStatuses: [404, 401, 400], + empty: false, + }, + requestOptions, + ), output: (input: ShellOutputInput, requestOptions?: RequestOptions) => request( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 807c8db36a..0d66ca1fc9 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -5757,25 +5757,25 @@ export type ShellCreateInput = { readonly command: { readonly command: string readonly cwd?: string - readonly timeout?: number + readonly timeout: number readonly metadata?: { readonly [x: string]: JsonValue } }["command"] readonly cwd?: { readonly command: string readonly cwd?: string - readonly timeout?: number + readonly timeout: number readonly metadata?: { readonly [x: string]: JsonValue } }["cwd"] - readonly timeout?: { + readonly timeout: { readonly command: string readonly cwd?: string - readonly timeout?: number + readonly timeout: number readonly metadata?: { readonly [x: string]: JsonValue } }["timeout"] readonly metadata?: { readonly command: string readonly cwd?: string - readonly timeout?: number + readonly timeout: number readonly metadata?: { readonly [x: string]: JsonValue } }["metadata"] } @@ -5833,6 +5833,37 @@ export type ShellGetOutput = { } } +export type ShellTimeoutInput = { + readonly id: { readonly id: string }["id"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly timeout: { readonly timeout: number }["timeout"] +} + +export type ShellTimeoutOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly id: string + readonly status: "running" | "exited" | "timeout" | "killed" + readonly command: string + readonly cwd: string + readonly shell: string + readonly file: string + readonly pid?: number + readonly exit?: number | "Infinity" | "-Infinity" | "NaN" + readonly metadata: { readonly [x: string]: JsonValue } + readonly time: { + readonly started: number | "Infinity" | "-Infinity" | "NaN" + readonly completed?: number | "Infinity" | "-Infinity" | "NaN" + } + } +} + export type ShellOutputInput = { readonly id: { readonly id: string }["id"] readonly location?: { diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 96a0519865..827adb261f 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -539,7 +539,7 @@ const layer = Layer.effect( if ((yield* execution.active).has(input.sessionID)) yield* execution.awaitIdle(input.sessionID) const started = yield* Effect.gen(function* () { const shell = yield* Shell.Service - return yield* shell.create({ command: input.command, cwd: session.location.directory }) + return yield* shell.create({ command: input.command, cwd: session.location.directory, timeout: 0 }) }).pipe(Effect.provide(locations.get(session.location))) yield* events.publish( SessionEvent.Shell.Started, diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index 852e19abd7..a62fef8107 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -32,6 +32,7 @@ type Active = { // started after termination resolves immediately from the already-completed deferred. done: Deferred.Deferred timeoutFiber?: Fiber.Fiber + timeout?: (duration: number) => Effect.Effect } /** @@ -50,6 +51,8 @@ export interface Interface { // Resolves once the command reaches a terminal status, returning its final Info. Fails with // NotFoundError if the command is unknown or is removed before it terminates. readonly wait: (id: Shell.ID) => Effect.Effect + // Replaces the running command's timeout from now; zero clears it. + readonly timeout: (id: Shell.ID, duration: number) => Effect.Effect readonly output: (id: Shell.ID, input?: Shell.OutputInput) => Effect.Effect readonly remove: (id: Shell.ID) => Effect.Effect } @@ -124,6 +127,13 @@ export const layer = Layer.effect( return yield* Deferred.await((yield* require(id)).done) }) + const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) { + const session = yield* require(id) + if (session.info.status !== "running" || !session.timeout) return session.info + yield* session.timeout(duration) + return session.info + }) + const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) { const session = yield* require(id) const cursor = input?.cursor ?? 0 @@ -265,16 +275,22 @@ export const layer = Layer.effect( if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber) }) - if (input.timeout) { - session.timeoutFiber = runFork( - Effect.sleep(Duration.millis(input.timeout)).pipe( - Effect.flatMap(() => - finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))), + session.timeout = (duration) => + Effect.gen(function* () { + if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber) + session.timeoutFiber = undefined + if (duration === 0 || session.info.status !== "running") return + session.timeoutFiber = runFork( + Effect.sleep(Duration.millis(duration)).pipe( + Effect.flatMap(() => + finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))), + ), + Effect.catch(() => Effect.void), ), - Effect.catch(() => Effect.void), - ), - ) - } + ) + }) + + yield* session.timeout(input.timeout) runFork( handle.exitCode.pipe( @@ -296,7 +312,7 @@ export const layer = Layer.effect( return session.info }) - return Service.of({ create, list, get, wait, output, remove }) + return Service.of({ create, list, get, wait, timeout, output, remove }) }), ) diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index 7c2a87b043..ca6b9f1969 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -8,7 +8,7 @@ import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" import { PermissionV2 } from "../permission" import { PluginRuntime } from "../plugin/runtime" -import { PositiveInt } from "../schema" +import { NonNegativeInt } from "../schema" import { SessionSchema } from "../session/schema" import { Shell } from "../shell" import { Tool, type Content } from "./tool" @@ -27,10 +27,10 @@ export const Input = Schema.Struct({ workdir: Schema.String.pipe(Schema.optional).annotate({ description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.", }), - timeout: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS)) + timeout: NonNegativeInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS)) .pipe(Schema.optional) .annotate({ - description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS} and may not exceed ${MAX_TIMEOUT_MS}.`, + description: `Optional timeout in milliseconds. Zero means unlimited. Foreground commands default to ${DEFAULT_TIMEOUT_MS}; background commands default to unlimited. May not exceed ${MAX_TIMEOUT_MS}.`, }), background: Schema.Boolean.pipe(Schema.optional).annotate({ description: @@ -143,7 +143,7 @@ export const Plugin = { draft.add( name, Tool.make({ - description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`, + description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. An optional timeout may be provided in milliseconds (zero: unlimited; foreground default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Background commands default to unlimited. Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`, input: Input, output: Output, structured: StructuredOutput, @@ -191,7 +191,7 @@ export const Plugin = { if ((yield* fsUtil.stat(target.canonical)).type !== "Directory") return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) - const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS + const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS) const info = yield* shell.create({ command: input.command, cwd: target.canonical, @@ -252,6 +252,7 @@ export const Plugin = { .block({ id: job.id, sessionID: context.sessionID }) .pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore))) if (result?.type === "backgrounded") { + yield* shell.timeout(info.id, 0) yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command) return { output: BACKGROUND_STARTED, diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 678e4a7c93..e92465fc3d 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -2,7 +2,7 @@ import fs from "fs/promises" import { realpathSync } from "node:fs" import path from "path" import { describe, expect, test } from "bun:test" -import { DateTime, Effect, Fiber, Layer, Scope } from "effect" +import { DateTime, Duration, Effect, Fiber, Layer, Scope } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" @@ -442,7 +442,10 @@ describe("ShellTool", () => { reset() return withSession(tmp.path, (registry) => Effect.gen(function* () { - const settled = yield* settleTool(registry, call({ command: idleCommand, background: true })) + const settled = yield* settleTool( + registry, + call({ command: idleCommand, timeout: 50, background: true }), + ) const structured = settled.output?.structured as Record | undefined const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined expect(settled.output?.structured).toMatchObject({ truncated: false }) @@ -452,7 +455,45 @@ describe("ShellTool", () => { if (!shellID) return const id = ShellSchema.ID.make(shellID) expect((yield* shell.list()).map((info) => info.id)).toContain(id) - yield* shell.remove(id) + expect((yield* shell.wait(id)).status).toBe("timeout") + }), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), + ), + ) + + it.live("updates and clears a running shell timeout", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + return withSession(tmp.path, (registry) => + Effect.gen(function* () { + const shell = yield* Shell.Service + const timed = yield* settleTool( + registry, + call({ command: idleCommand, background: true }, "call-updated-timeout"), + ) + const timedID = (timed.output?.structured as Record | undefined)?.shellID + expect(typeof timedID).toBe("string") + if (typeof timedID !== "string") return + const timedShellID = ShellSchema.ID.make(timedID) + yield* shell.timeout(timedShellID, 50) + expect((yield* shell.wait(timedShellID)).status).toBe("timeout") + + const cleared = yield* settleTool( + registry, + call({ command: idleCommand, timeout: 50, background: true }, "call-cleared-timeout"), + ) + const clearedID = (cleared.output?.structured as Record | undefined)?.shellID + expect(typeof clearedID).toBe("string") + if (typeof clearedID !== "string") return + const clearedShellID = ShellSchema.ID.make(clearedID) + yield* shell.timeout(clearedShellID, 0) + yield* Effect.sleep(Duration.millis(100)) + expect((yield* shell.get(clearedShellID)).status).toBe("running") + yield* shell.remove(clearedShellID) }), ) }, @@ -469,9 +510,10 @@ describe("ShellTool", () => { Effect.gen(function* () { const jobs = yield* Job.Service const scope = yield* Scope.Scope - const waiting = yield* settleTool(registry, call({ command: idleCommand }, "call-background-signal")).pipe( - Effect.forkIn(scope, { startImmediately: true }), - ) + const waiting = yield* settleTool( + registry, + call({ command: idleCommand, timeout: 50 }, "call-background-signal"), + ).pipe(Effect.forkIn(scope, { startImmediately: true })) const backgroundWhenReady = (remaining = 1000): Effect.Effect => Effect.gen(function* () { @@ -482,7 +524,6 @@ describe("ShellTool", () => { return yield* backgroundWhenReady(remaining - 1) }) expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }]) - const settled = yield* Fiber.join(waiting) const structured = settled.output?.structured as Record | undefined const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined @@ -500,6 +541,8 @@ describe("ShellTool", () => { const shell = yield* Shell.Service if (!shellID) return const id = ShellSchema.ID.make(shellID) + yield* Effect.sleep(Duration.millis(100)) + expect((yield* shell.get(id)).status).toBe("running") expect((yield* shell.list()).map((info) => info.id)).toContain(id) yield* shell.remove(id) }), diff --git a/packages/protocol/src/groups/shell.ts b/packages/protocol/src/groups/shell.ts index 31fe35541e..0b4cc081f6 100644 --- a/packages/protocol/src/groups/shell.ts +++ b/packages/protocol/src/groups/shell.ts @@ -1,10 +1,15 @@ import { Shell } from "@opencode-ai/schema/shell" import { Location } from "@opencode-ai/schema/location" +import { NonNegativeInt } from "@opencode-ai/schema/schema" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { ShellNotFoundError } from "../errors.js" import { LocationQuery, locationQueryOpenApi } from "./location.js" +const TimeoutInput = Schema.Struct({ + timeout: NonNegativeInt, +}) + export const ShellGroup = HttpApiGroup.make("server.shell") .add( HttpApiEndpoint.get("shell.list", "/api/shell", { @@ -52,6 +57,23 @@ export const ShellGroup = HttpApiGroup.make("server.shell") }), ), ) + .add( + HttpApiEndpoint.patch("shell.timeout", "/api/shell/:id/timeout", { + params: { id: Shell.ID }, + query: LocationQuery, + payload: TimeoutInput, + success: Location.response(Shell.Info), + error: ShellNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.shell.timeout", + summary: "Update shell timeout", + description: "Replace a running shell command's timeout from now, or clear it with zero.", + }), + ), + ) .add( HttpApiEndpoint.get("shell.output", "/api/shell/:id/output", { params: { id: Shell.ID }, diff --git a/packages/schema/src/shell.ts b/packages/schema/src/shell.ts index fbe877c974..1826752d37 100644 --- a/packages/schema/src/shell.ts +++ b/packages/schema/src/shell.ts @@ -57,7 +57,7 @@ export const Event = { Created, Exited, Deleted, Definitions: inventory(Created, export const CreateInput = Schema.Struct({ command: Schema.String, cwd: optional(Schema.String), - timeout: optional(NonNegativeInt), + timeout: NonNegativeInt, metadata: optional(Metadata), }) export interface CreateInput extends Schema.Schema.Type {} diff --git a/packages/server/src/handlers/shell.ts b/packages/server/src/handlers/shell.ts index 6f4c57d538..ac1ae09a8c 100644 --- a/packages/server/src/handlers/shell.ts +++ b/packages/server/src/handlers/shell.ts @@ -38,6 +38,20 @@ export const ShellHandler = HttpApiBuilder.group(Api, "server.shell", (handlers) ) }), ) + .handle( + "shell.timeout", + Effect.fn(function* (ctx) { + const shell = yield* Shell.Service + return yield* response( + shell.timeout(ctx.params.id, ctx.payload.timeout).pipe( + Effect.catchTag( + "Shell.NotFoundError", + () => new ShellNotFoundError({ id: ctx.params.id, message: `Shell command not found: ${ctx.params.id}` }), + ), + ), + ) + }), + ) .handle( "shell.output", Effect.fn(function* (ctx) {