Skip to content

Commit 1813c42

Browse files
committed
feat(run): wire RunEvents handler into non-attach path
Constructs RunEvents.make after sessionID resolution when !args.attach, wraps event loop in try/finally for unsubscribe cleanup, and removes sdk.permission.reply from the non-attach switch arm so the handler owns the reply (prevents double-reply). Attach path unchanged. session() now returns SessionID-branded values via SessionID.make() to satisfy the RunEvents.Config contract without casts. SessionID brand has no runtime validation so this is zero-cost. Plan: docs/superpowers/plans/2026-04-18-subagent-hang-hardening.md B.2b Reviewers: general (spec) + codex-5.3 (code quality), both APPROVE.
1 parent 64e0b68 commit 1813c42

1 file changed

Lines changed: 134 additions & 104 deletions

File tree

  • packages/opencode/src/cli/cmd

packages/opencode/src/cli/cmd/run.ts

Lines changed: 134 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import { BashTool } from "../../tool/bash"
2727
import { TodoWriteTool } from "../../tool/todo"
2828
import { Locale } from "../../util"
2929
import { AppRuntime } from "@/effect/app-runtime"
30+
import { SessionID } from "@/session/schema"
31+
import { RunEvents } from "./run-events"
3032

3133
type ToolProps<T> = {
3234
input: Tool.InferParameters<T>
@@ -380,14 +382,14 @@ export const RunCommand = cmd({
380382

381383
if (baseID && args.fork) {
382384
const forked = await sdk.session.fork({ sessionID: baseID })
383-
return forked.data?.id
385+
return forked.data?.id ? SessionID.make(forked.data.id) : undefined
384386
}
385387

386-
if (baseID) return baseID
388+
if (baseID) return SessionID.make(baseID)
387389

388390
const name = title()
389391
const result = await sdk.session.create({ title: name, permission: rules })
390-
return result.data?.id
392+
return result.data?.id ? SessionID.make(result.data.id) : undefined
391393
}
392394

393395
async function share(sdk: OpencodeClient, sessionID: string) {
@@ -406,6 +408,8 @@ export const RunCommand = cmd({
406408
}
407409

408410
async function execute(sdk: OpencodeClient) {
411+
const jsonMode = args.format === "json"
412+
409413
function tool(part: ToolPart) {
410414
try {
411415
if (part.tool === "bash") return bash(props<typeof BashTool>(part))
@@ -427,7 +431,7 @@ export const RunCommand = cmd({
427431
}
428432

429433
function emit(type: string, data: Record<string, unknown>) {
430-
if (args.format === "json") {
434+
if (jsonMode) {
431435
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
432436
return true
433437
}
@@ -440,124 +444,139 @@ export const RunCommand = cmd({
440444
async function loop() {
441445
const toggles = new Map<string, boolean>()
442446

443-
for await (const event of events.stream) {
444-
if (
445-
event.type === "message.updated" &&
446-
event.properties.info.role === "assistant" &&
447-
args.format !== "json" &&
448-
toggles.get("start") !== true
449-
) {
450-
UI.empty()
451-
UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)
452-
UI.empty()
453-
toggles.set("start", true)
454-
}
455-
456-
if (event.type === "message.part.updated") {
457-
const part = event.properties.part
458-
if (part.sessionID !== sessionID) continue
459-
460-
if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
461-
if (emit("tool_use", { part })) continue
462-
if (part.state.status === "completed") {
463-
tool(part)
464-
continue
465-
}
466-
inline({
467-
icon: "✗",
468-
title: `${part.tool} failed`,
469-
})
470-
UI.error(part.state.error)
471-
}
472-
447+
try {
448+
for await (const event of events.stream) {
473449
if (
474-
part.type === "tool" &&
475-
part.tool === "task" &&
476-
part.state.status === "running" &&
477-
args.format !== "json"
450+
event.type === "message.updated" &&
451+
event.properties.info.role === "assistant" &&
452+
args.format !== "json" &&
453+
toggles.get("start") !== true
478454
) {
479-
if (toggles.get(part.id) === true) continue
480-
task(props<typeof TaskTool>(part))
481-
toggles.set(part.id, true)
455+
UI.empty()
456+
UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)
457+
UI.empty()
458+
toggles.set("start", true)
482459
}
483460

484-
if (part.type === "step-start") {
485-
if (emit("step_start", { part })) continue
486-
}
461+
if (event.type === "message.part.updated") {
462+
const part = event.properties.part
463+
if (part.sessionID !== sessionID) continue
464+
465+
if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
466+
if (emit("tool_use", { part })) continue
467+
if (part.state.status === "completed") {
468+
tool(part)
469+
continue
470+
}
471+
inline({
472+
icon: "✗",
473+
title: `${part.tool} failed`,
474+
})
475+
UI.error(part.state.error)
476+
}
487477

488-
if (part.type === "step-finish") {
489-
if (emit("step_finish", { part })) continue
490-
}
478+
if (
479+
part.type === "tool" &&
480+
part.tool === "task" &&
481+
part.state.status === "running" &&
482+
args.format !== "json"
483+
) {
484+
if (toggles.get(part.id) === true) continue
485+
task(props<typeof TaskTool>(part))
486+
toggles.set(part.id, true)
487+
}
491488

492-
if (part.type === "text" && part.time?.end) {
493-
if (emit("text", { part })) continue
494-
const text = part.text.trim()
495-
if (!text) continue
496-
if (!process.stdout.isTTY) {
497-
process.stdout.write(text + EOL)
498-
continue
489+
if (part.type === "step-start") {
490+
if (emit("step_start", { part })) continue
499491
}
500-
UI.empty()
501-
UI.println(text)
502-
UI.empty()
503-
}
504492

505-
if (part.type === "reasoning" && part.time?.end && args.thinking) {
506-
if (emit("reasoning", { part })) continue
507-
const text = part.text.trim()
508-
if (!text) continue
509-
const line = `Thinking: ${text}`
510-
if (process.stdout.isTTY) {
493+
if (part.type === "step-finish") {
494+
if (emit("step_finish", { part })) continue
495+
}
496+
497+
if (part.type === "text" && part.time?.end) {
498+
if (emit("text", { part })) continue
499+
const text = part.text.trim()
500+
if (!text) continue
501+
if (!process.stdout.isTTY) {
502+
process.stdout.write(text + EOL)
503+
continue
504+
}
511505
UI.empty()
512-
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
506+
UI.println(text)
513507
UI.empty()
514-
continue
515508
}
516-
process.stdout.write(line + EOL)
509+
510+
if (part.type === "reasoning" && part.time?.end && args.thinking) {
511+
if (emit("reasoning", { part })) continue
512+
const text = part.text.trim()
513+
if (!text) continue
514+
const line = `Thinking: ${text}`
515+
if (process.stdout.isTTY) {
516+
UI.empty()
517+
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
518+
UI.empty()
519+
continue
520+
}
521+
process.stdout.write(line + EOL)
522+
}
517523
}
518-
}
519524

520-
if (event.type === "session.error") {
521-
const props = event.properties
522-
if (props.sessionID !== sessionID || !props.error) continue
523-
let err = String(props.error.name)
524-
if ("data" in props.error && props.error.data && "message" in props.error.data) {
525-
err = String(props.error.data.message)
525+
if (event.type === "session.error") {
526+
const props = event.properties
527+
if (props.sessionID !== sessionID || !props.error) continue
528+
let err = String(props.error.name)
529+
if ("data" in props.error && props.error.data && "message" in props.error.data) {
530+
err = String(props.error.data.message)
531+
}
532+
error = error ? error + EOL + err : err
533+
if (emit("error", { error: props.error })) continue
534+
UI.error(err)
526535
}
527-
error = error ? error + EOL + err : err
528-
if (emit("error", { error: props.error })) continue
529-
UI.error(err)
530-
}
531536

532-
if (
533-
event.type === "session.status" &&
534-
event.properties.sessionID === sessionID &&
535-
event.properties.status.type === "idle"
536-
) {
537-
break
538-
}
537+
if (
538+
event.type === "session.status" &&
539+
event.properties.sessionID === sessionID &&
540+
event.properties.status.type === "idle"
541+
) {
542+
break
543+
}
544+
545+
if (event.type === "permission.asked") {
546+
const permission = event.properties
547+
if (permission.sessionID !== sessionID) continue
548+
549+
if (runEventsHandle) {
550+
if (!args["dangerously-skip-permissions"] && !jsonMode) {
551+
UI.println(
552+
UI.Style.TEXT_WARNING_BOLD + "!",
553+
UI.Style.TEXT_NORMAL +
554+
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
555+
)
556+
}
557+
continue
558+
}
539559

540-
if (event.type === "permission.asked") {
541-
const permission = event.properties
542-
if (permission.sessionID !== sessionID) continue
543-
544-
if (args["dangerously-skip-permissions"]) {
545-
await sdk.permission.reply({
546-
requestID: permission.id,
547-
reply: "once",
548-
})
549-
} else {
550-
UI.println(
551-
UI.Style.TEXT_WARNING_BOLD + "!",
552-
UI.Style.TEXT_NORMAL +
553-
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
554-
)
555-
await sdk.permission.reply({
556-
requestID: permission.id,
557-
reply: "reject",
558-
})
560+
if (args["dangerously-skip-permissions"]) {
561+
await sdk.permission.reply({
562+
requestID: permission.id,
563+
reply: "once",
564+
})
565+
} else {
566+
UI.println(
567+
UI.Style.TEXT_WARNING_BOLD + "!",
568+
UI.Style.TEXT_NORMAL +
569+
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
570+
)
571+
await sdk.permission.reply({
572+
requestID: permission.id,
573+
reply: "reject",
574+
})
575+
}
559576
}
560577
}
578+
} finally {
579+
runEventsHandle?.unsubscribe()
561580
}
562581
}
563582

@@ -629,6 +648,17 @@ export const RunCommand = cmd({
629648
UI.error("Session not found")
630649
process.exit(1)
631650
}
651+
652+
const runEventsHandle = args.attach
653+
? null
654+
: await AppRuntime.runPromise(
655+
RunEvents.make({
656+
rootSessionID: sessionID,
657+
skipPermissions: args["dangerously-skip-permissions"] === true,
658+
jsonMode,
659+
}),
660+
)
661+
632662
await share(sdk, sessionID)
633663

634664
loop().catch((e) => {

0 commit comments

Comments
 (0)