Skip to content

Commit 7ed6e54

Browse files
See USee U
authored andcommitted
feat(core): implement v2 session shell execution
1 parent 3053efc commit 7ed6e54

9 files changed

Lines changed: 256 additions & 17 deletions

File tree

MERGE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ fork 与上游改了同一处(常见于 TUI 视觉/UX、core 加固逻辑)
167167
| `packages/core/src/{session.ts,session/compaction.ts,session/history.ts,session/runner/*}` (v2 手动 compact 实现) | 中 | 保留:实现 `SessionV2.compact`(原 stub 恒 `OperationUnavailableError`),spec `specs/v2/session.md:105` 已列为 sanctioned improvement。`SessionCompaction.make` 抽出共享 `summarize` 核心(`reason: "auto"\|"manual"`),新增 `compactManually`(输出预算取 `model.route.defaults.limits?.output`,可选 `instructions` 折入 `buildPrompt`);`SessionHistory.load` 改由新导出 `entries()`(读 epoch `baseline_seq` 只读,不触发 SystemContext 初始化)派生;`SessionRunner` 接口新增 `compact`,`SessionV2.compact` 经 `locations.get(session.location)` 路由,失败/无可压缩内容统一映射 `OperationUnavailableError`(HTTP 503 文案不变)。已知限制:手动 compact 不经 `SessionRunCoordinator` 串行化,与进行中 drain 并发时下一 turn 才生效;测试 `packages/core/test/session-runner.test.ts`(manually compacts on demand / returns false when nothing to compact)(上游实现 v2 手动 compact 后取上游版本) |
168168
| `packages/core/src/{session.ts,session/run-coordinator.ts,session/execution.ts,session/execution/local.ts}` (v2 `SessionV2.wait` 实现) || 保留:实现 `SessionV2.wait`(原 stub 恒 `OperationUnavailableError`)。逐字对齐上游 in-flight 提交 `5e90a68d6a`(wip(core): v2 subagent foundations):`SessionRunCoordinator.awaitIdle`(await entry `done` + `Effect.exit` 吞掉失败/中断、循环复查 successor 直到 idle,空闲即 no-op、不发起 drain),`SessionExecution.awaitIdle` 透传,`SessionV2.wait` 委托并收窄为 `NotFoundError`,server handler 移除死的 503 映射,literals 移除 `"wait"`(对齐 `bd8d858bf7` 前态)。语义限制:仅覆盖本进程持有的执行;测试为 fork 补充(上游 wip 无测试):`packages/core/test/session-run-coordinator.test.ts`(awaitIdle 4 例)+ `packages/opencode/test/server/httpapi-session.test.ts`(wait 改期望 204)(上游该分支合入后按上游版本对齐) |
169169
| `packages/{schema,core,protocol,server}/src/**` + `packages/sdk/js/src/v2/gen` (v2 `SessionV2.skill` 实现) | 中 | 保留:实现 `SessionV2.skill`(原 stub 恒 `OperationUnavailableError`)。逐字移植上游 in-flight 提交 `23adaaaeab`(feat(core): add native skill activation):durable `SessionEvent.Skill.Activated` + `SessionMessage.Skill` + projector/message-updater/to-llm-message/compaction 全链路,`SessionV2.skill` 经 `locations.get(session.location)` 解析 `SkillV2.list()`、未命中报 `Session.SkillNotFoundError`、`resume !== false` 时 fork `execution.resume`,协议新增 `POST /api/session/:sessionID/skill`(404 `SkillNotFoundError`)+ server handler + SDK v2 重新生成。未移植部分:TUI skill UX(依赖上游更新的 useLocation 管线)、report skill、cli daemon、plugin skill.ts、packages/client gen。测试为 fork 补充:`packages/core/test/session-create.test.ts`(activates a known skill as a durable skill message)+ httpapi-exercise/httpapi-session 覆盖;manifest 计数随 durable 事件 +1(60/90/90/36)(上游该分支合入后按上游版本对齐) |
170+
| `packages/{core,protocol,server}/src/**` + `packages/sdk/js/src/v2/gen` (v2 `SessionV2.shell` 实现) | 中 | 保留:实现 `SessionV2.shell`(原 stub 恒 `OperationUnavailableError`)。结构逐字对齐上游提交 `bd8d858bf7`(feat(core): implement V2 session.shell):`KeyedMutex` 按 Session 串行、`activeShells` 抑制 shell 期间 prompt wake、`execution.awaitIdle` 等待在跑 drain、durable `Shell.Started`/`Shell.Ended`(事件在 HEAD 已入 manifest,计数不变)、`Effect.ensuring` 清理 + `execution.wake`,协议新增 `POST /api/session/:sessionID/shell`(404 `SessionNotFoundError`)+ server handler + SDK v2 重新生成。唯一偏离:`runShellCommand` 内部用 HEAD 已有原语 `AppProcess.run` + `Shell.preferred`/`Shell.args`(v1 用户 shell 语义:config shell、TERM=dumb、extendEnv、stdin ignore、forceKillAfter 3s、合并输出、1MiB 截断、`AppProcessError` 捕获为输出文本)替代上游尚未移植的 location `Shell.Service`(`5ae93092aa`);`core/src/shell.ts` 保持原样。测试为 fork 补充:`packages/core/test/session-create.test.ts`(shell 3 例)+ httpapi-exercise/httpapi-session 覆盖(上游 `5ae93092aa`+`bd8d858bf7` 移植后取上游版本) |
170171

171172
### TUI 偏离(四批 23 轮审计打磨,全域)
172173

packages/core/src/session.ts

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
export * as SessionV2 from "./session"
22
export * from "./session/schema"
33

4-
import { DateTime, Effect, Layer, Schema, Context, Stream, Scope } from "effect"
4+
import { DateTime, Duration, Effect, Layer, Schema, Context, Stream, Scope } from "effect"
5+
import { ChildProcess } from "effect/unstable/process"
56
import { ListAnchor } from "@opencode-ai/schema/session"
67
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
78
import { ProjectV2 } from "./project"
@@ -38,6 +39,11 @@ import { Revert } from "@opencode-ai/schema/revert"
3839
import { FSUtil } from "./fs-util"
3940
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
4041
import { SkillV2 } from "./skill"
42+
import { Identifier } from "./util/identifier"
43+
import { Shell } from "./shell"
44+
import { KeyedMutex } from "./effect/keyed-mutex"
45+
import { AppProcess } from "./process"
46+
import { Config } from "./config"
4147

4248
export const RevertState = Revert.State
4349
export type RevertState = Revert.State
@@ -96,7 +102,7 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Ses
96102
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
97103
"Session.OperationUnavailableError",
98104
{
99-
operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact"]),
105+
operation: Schema.Literals(["move", "skill", "switchAgent", "compact"]),
100106
},
101107
) {}
102108

@@ -159,8 +165,7 @@ export interface Interface {
159165
id?: EventV2.ID
160166
sessionID: SessionSchema.ID
161167
command: string
162-
resume?: boolean
163-
}) => Effect.Effect<void, OperationUnavailableError>
168+
}) => Effect.Effect<void, NotFoundError>
164169
readonly skill: (input: {
165170
id?: SessionMessage.ID
166171
sessionID: SessionSchema.ID
@@ -195,7 +200,10 @@ const layer = Layer.effect(
195200
const execution = yield* SessionExecution.Service
196201
const store = yield* SessionStore.Service
197202
const locations = yield* LocationServiceMap.Service
203+
const appProcess = yield* AppProcess.Service
198204
const scope = yield* Scope.Scope
205+
const activeShells = new Set<SessionSchema.ID>()
206+
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
199207
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
200208
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
201209
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
@@ -209,6 +217,26 @@ const layer = Layer.effect(
209217
),
210218
)
211219

220+
// Session shell is user-initiated and synchronous at the API boundary. The
221+
// upstream location Shell service is not at this HEAD yet, so run through
222+
// AppProcess with the v1 user-facing shell selection semantics.
223+
const runShellCommand = (command: string, cwd: string) =>
224+
Effect.gen(function* () {
225+
const config = yield* Config.Service
226+
const sh = Shell.preferred(Config.latest(yield* config.entries(), "shell"))
227+
const result = yield* appProcess.run(
228+
ChildProcess.make(sh, Shell.args(sh, command, cwd), {
229+
cwd,
230+
extendEnv: true,
231+
env: { TERM: "dumb" },
232+
stdin: "ignore",
233+
forceKillAfter: Duration.seconds(3),
234+
}),
235+
{ combineOutput: true, maxOutputBytes: SHELL_MAX_CAPTURE_BYTES },
236+
)
237+
return result.output?.toString("utf8") || "(no output)"
238+
}).pipe(Effect.catchTag("AppProcessError", (error) => Effect.succeed(error.message)))
239+
212240
const result = Service.of({
213241
create: Effect.fn("V2Session.create")(function* (input) {
214242
const sessionID = input.id ?? SessionSchema.ID.create()
@@ -384,13 +412,51 @@ const layer = Layer.effect(
384412
)
385413
if (!SessionInput.equivalent(admitted, expected))
386414
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
387-
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
415+
if (input.resume !== false) {
416+
if (activeShells.has(admitted.sessionID)) return admitted
417+
yield* execution.wake(admitted.sessionID)
418+
}
388419
return admitted
389420
}),
390421
),
391422
),
392-
shell: Effect.fn("V2Session.shell")(function* () {
393-
return yield* new OperationUnavailableError({ operation: "shell" })
423+
shell: Effect.fn("V2Session.shell")(function* (input) {
424+
const session = yield* result.get(input.sessionID)
425+
yield* shellLocks.withLock(input.sessionID)(
426+
Effect.gen(function* () {
427+
activeShells.add(input.sessionID)
428+
if ((yield* execution.active).has(input.sessionID)) yield* execution.awaitIdle(input.sessionID)
429+
const messageID = SessionMessage.ID.create()
430+
const callID = Identifier.ascending()
431+
yield* events.publish(
432+
SessionEvent.Shell.Started,
433+
{
434+
sessionID: input.sessionID,
435+
messageID,
436+
callID,
437+
command: input.command,
438+
timestamp: yield* DateTime.now,
439+
},
440+
{ id: input.id },
441+
)
442+
const output = yield* runShellCommand(input.command, session.location.directory).pipe(
443+
Effect.provide(locations.get(session.location)),
444+
)
445+
yield* events.publish(SessionEvent.Shell.Ended, {
446+
sessionID: input.sessionID,
447+
callID,
448+
output,
449+
timestamp: yield* DateTime.now,
450+
})
451+
}).pipe(
452+
Effect.ensuring(
453+
Effect.gen(function* () {
454+
activeShells.delete(input.sessionID)
455+
yield* execution.wake(input.sessionID)
456+
}),
457+
),
458+
),
459+
)
394460
}),
395461
skill: Effect.fn("V2Session.skill")(function* (input) {
396462
const session = yield* result.get(input.sessionID)
@@ -497,6 +563,9 @@ const resolvePrompt = (input: PromptInput.Prompt) =>
497563
}),
498564
})
499565

566+
// Mirrors the shell tool's in-memory preview safety limit.
567+
const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
568+
500569
export const node = makeGlobalNode({
501570
service: Service,
502571
layer: layer.pipe(Layer.orDie),
@@ -508,5 +577,6 @@ export const node = makeGlobalNode({
508577
SessionStore.node,
509578
LocationServiceMap.node,
510579
SessionProjector.node,
580+
AppProcess.node,
511581
],
512582
})

packages/core/test/session-create.test.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -314,19 +314,55 @@ describe("SessionV2.create", () => {
314314
}),
315315
)
316316

317-
it.effect("reports unfinished Session operations as unavailable", () =>
317+
it.live("runs a shell command as a durable shell message", () =>
318318
Effect.gen(function* () {
319319
const session = yield* SessionV2.Service
320-
const created = yield* session.create({ location })
321-
const unavailable = (
322-
effect: Effect.Effect<void, SessionV2.NotFoundError | SessionV2.OperationUnavailableError>,
323-
) =>
324-
effect.pipe(
325-
Effect.flip,
326-
Effect.map((error) => (error instanceof SessionV2.OperationUnavailableError ? error.operation : "not-found")),
327-
)
320+
const tmp = yield* Effect.acquireRelease(
321+
Effect.promise(() => tmpdir()),
322+
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
323+
)
324+
const created = yield* session.create({
325+
location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
326+
})
327+
328+
yield* session.shell({ sessionID: created.id, command: "echo hello" })
328329

329-
expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell")
330+
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
331+
expect(messages).toMatchObject([{ type: "shell", command: "echo hello", output: "hello\n" }])
332+
expect(messages[0]?.type === "shell" && messages[0].time.completed !== undefined).toBe(true)
333+
}),
334+
)
335+
336+
it.live("records nonzero exits and empty output as a completed shell message", () =>
337+
Effect.gen(function* () {
338+
const session = yield* SessionV2.Service
339+
const tmp = yield* Effect.acquireRelease(
340+
Effect.promise(() => tmpdir()),
341+
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
342+
)
343+
const created = yield* session.create({
344+
location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
345+
})
346+
347+
yield* session.shell({ sessionID: created.id, command: "exit 7" })
348+
349+
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
350+
{ type: "shell", command: "exit 7", output: "(no output)" },
351+
])
352+
}),
353+
)
354+
355+
it.effect("rejects a shell command for a missing Session", () =>
356+
Effect.gen(function* () {
357+
const session = yield* SessionV2.Service
358+
const missing = SessionV2.ID.make("ses_missing_shell")
359+
360+
expect(
361+
yield* session.shell({ sessionID: missing, command: "pwd" }).pipe(
362+
Effect.flip,
363+
Effect.map((error) => error._tag),
364+
),
365+
).toBe("Session.NotFoundError")
330366
}),
331367
)
332368

packages/opencode/test/server/httpapi-exercise/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,6 +1143,14 @@ const scenarios: Scenario[] = [
11431143
body: { skill: "review" },
11441144
}))
11451145
.status(404, undefined, "status"),
1146+
http.protected
1147+
.post("/api/session/{sessionID}/shell", "v2.session.shell")
1148+
.at((ctx) => ({
1149+
path: route("/api/session/{sessionID}/shell", { sessionID: "ses_httpapi_missing" }),
1150+
headers: ctx.headers(),
1151+
body: { command: "pwd" },
1152+
}))
1153+
.status(404, undefined, "status"),
11461154
http.protected
11471155
.post("/api/session/{sessionID}/compact", "v2.session.compact")
11481156
.at((ctx) => ({

packages/opencode/test/server/httpapi-session.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,14 @@ describe("session HttpApi", () => {
565565
})
566566
expect(skill.status).toBe(404)
567567
expect(yield* responseJson(skill)).toEqual(expected)
568+
569+
const shell = yield* request(`/api/session/${missing}/shell`, {
570+
method: "POST",
571+
headers: { ...headers, "content-type": "application/json" },
572+
body: JSON.stringify({ command: "pwd" }),
573+
})
574+
expect(shell.status).toBe(404)
575+
expect(yield* responseJson(shell)).toEqual(expected)
568576
}),
569577
{ git: true, config: { formatter: false, lsp: false } },
570578
)

packages/protocol/src/groups/session.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { Model } from "@opencode-ai/schema/model"
2222
import { Location } from "@opencode-ai/schema/location"
2323
import { Revert } from "@opencode-ai/schema/revert"
2424
import { SessionEvent } from "@opencode-ai/schema/session-event"
25+
import { Event } from "@opencode-ai/schema/event"
2526

2627
const SessionsQueryFields = {
2728
workspace: Workspace.ID.pipe(Schema.optional),
@@ -223,6 +224,26 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
223224
}),
224225
),
225226
)
227+
.add(
228+
HttpApiEndpoint.post("session.shell", "/api/session/:sessionID/shell", {
229+
params: { sessionID: Session.ID },
230+
payload: Schema.Struct({
231+
id: Event.ID.pipe(Schema.optional),
232+
command: Schema.String,
233+
}),
234+
success: HttpApiSchema.NoContent,
235+
error: SessionNotFoundError,
236+
})
237+
.middleware(sessionLocationMiddleware)
238+
.annotateMerge(
239+
OpenApi.annotations({
240+
identifier: "v2.session.shell",
241+
summary: "Run shell command",
242+
description:
243+
"Execute one shell command in the session's working directory. Emits a shell.started event before execution and a shell.ended event with the merged output after.",
244+
}),
245+
),
246+
)
226247
.add(
227248
HttpApiEndpoint.post("session.skill", "/api/session/:sessionID/skill", {
228249
params: { sessionID: Session.ID },

packages/sdk/js/src/v2/gen/sdk.gen.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,8 @@ import type {
365365
V2SessionRevertCommitResponses,
366366
V2SessionRevertStageErrors,
367367
V2SessionRevertStageResponses,
368+
V2SessionShellErrors,
369+
V2SessionShellResponses,
368370
V2SessionSkillErrors,
369371
V2SessionSkillResponses,
370372
V2SessionSwitchAgentErrors,
@@ -5413,6 +5415,43 @@ export class Session3 extends HeyApiClient {
54135415
})
54145416
}
54155417

5418+
/**
5419+
* Run shell command
5420+
*
5421+
* Execute one shell command in the session's working directory. Emits a shell.started event before execution and a shell.ended event with the merged output after.
5422+
*/
5423+
public shell<ThrowOnError extends boolean = false>(
5424+
parameters: {
5425+
sessionID: string
5426+
id?: string
5427+
command?: string
5428+
},
5429+
options?: Options<never, ThrowOnError>,
5430+
) {
5431+
const params = buildClientParams(
5432+
[parameters],
5433+
[
5434+
{
5435+
args: [
5436+
{ in: "path", key: "sessionID" },
5437+
{ in: "body", key: "id" },
5438+
{ in: "body", key: "command" },
5439+
],
5440+
},
5441+
],
5442+
)
5443+
return (options?.client ?? this.client).post<V2SessionShellResponses, V2SessionShellErrors, ThrowOnError>({
5444+
url: "/api/session/{sessionID}/shell",
5445+
...options,
5446+
...params,
5447+
headers: {
5448+
"Content-Type": "application/json",
5449+
...options?.headers,
5450+
...params.headers,
5451+
},
5452+
})
5453+
}
5454+
54165455
/**
54175456
* Activate skill
54185457
*

packages/sdk/js/src/v2/gen/types.gen.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11504,6 +11504,44 @@ export type V2SessionPromptResponses = {
1150411504

1150511505
export type V2SessionPromptResponse = V2SessionPromptResponses[keyof V2SessionPromptResponses]
1150611506

11507+
export type V2SessionShellData = {
11508+
body: {
11509+
id?: string
11510+
command: string
11511+
}
11512+
path: {
11513+
sessionID: string
11514+
}
11515+
query?: never
11516+
url: "/api/session/{sessionID}/shell"
11517+
}
11518+
11519+
export type V2SessionShellErrors = {
11520+
/**
11521+
* InvalidRequestError
11522+
*/
11523+
400: InvalidRequestError
11524+
/**
11525+
* UnauthorizedError
11526+
*/
11527+
401: UnauthorizedError
11528+
/**
11529+
* SessionNotFoundError
11530+
*/
11531+
404: SessionNotFoundError
11532+
}
11533+
11534+
export type V2SessionShellError = V2SessionShellErrors[keyof V2SessionShellErrors]
11535+
11536+
export type V2SessionShellResponses = {
11537+
/**
11538+
* <No Content>
11539+
*/
11540+
204: void
11541+
}
11542+
11543+
export type V2SessionShellResponse = V2SessionShellResponses[keyof V2SessionShellResponses]
11544+
1150711545
export type V2SessionSkillData = {
1150811546
body: {
1150911547
id?: string

0 commit comments

Comments
 (0)