@@ -23,9 +23,39 @@ import { errors } from "../error"
2323import { lazy } from "../../util/lazy"
2424import { Bus } from "../../bus"
2525import { NamedError } from "@opencode-ai/util/error"
26+ import { ToolRegistry } from "../../tool/registry"
2627
2728const log = Log . create ( { service : "server" } )
2829
30+ /** Walk parent session messages backwards to find the model used in the last user message. */
31+ async function resolveModel ( sessionID : SessionID ) {
32+ const msgs = await Session . messages ( { sessionID } )
33+ for ( let i = msgs . length - 1 ; i >= 0 ; i -- ) {
34+ const info = msgs [ i ] . info
35+ if ( info . role === "user" && info . model ) return info . model
36+ }
37+ }
38+
39+ /** Create an emitter for external ToolPart updates (no-op when messageID is absent). */
40+ function emitter ( opts : { sessionID : SessionID ; messageID ?: string ; tool : string } ) {
41+ const mid = opts . messageID ? MessageID . make ( opts . messageID ) : undefined
42+ const pid = mid ? PartID . ascending ( ) : undefined
43+ const fn = ( state : z . infer < typeof MessageV2 . ToolState > ) =>
44+ mid && pid
45+ ? Session . updatePart ( {
46+ id : pid ,
47+ messageID : mid ,
48+ sessionID : opts . sessionID ,
49+ type : "tool" as const ,
50+ tool : opts . tool ,
51+ callID : pid ,
52+ external : true ,
53+ state,
54+ } )
55+ : undefined
56+ return { mid, pid, fn }
57+ }
58+
2959export const SessionRoutes = lazy ( ( ) =>
3060 new Hono ( )
3161 . get (
@@ -1040,5 +1070,335 @@ export const SessionRoutes = lazy(() =>
10401070 } )
10411071 return c . json ( true )
10421072 } ,
1073+ )
1074+ // Direct tool execution — no LLM, deterministic. Enables plugins, tests,
1075+ // and scripts to execute tools outside the LLM event loop.
1076+ . post (
1077+ "/:sessionID/tool" ,
1078+ describeRoute ( {
1079+ summary : "Execute tool directly" ,
1080+ description :
1081+ "Execute an OpenCode tool without LLM involvement. Results are streamed as plain text. Optionally creates an external ToolPart for TUI visibility." ,
1082+ operationId : "session.tool" ,
1083+ responses : {
1084+ 200 : {
1085+ description : "Tool output as plain text" ,
1086+ content : { "text/plain" : { schema : resolver ( z . string ( ) ) } } ,
1087+ } ,
1088+ ...errors ( 400 , 404 ) ,
1089+ } ,
1090+ } ) ,
1091+ validator ( "param" , z . object ( { sessionID : SessionID . zod } ) ) ,
1092+ validator (
1093+ "json" ,
1094+ z . object ( {
1095+ name : z . string ( ) . describe ( "Tool name (e.g. read, edit, grep, glob)" ) ,
1096+ args : z . record ( z . string ( ) , z . unknown ( ) ) . describe ( "Tool arguments" ) ,
1097+ agent : z . string ( ) . optional ( ) . describe ( "Agent context for permissions" ) ,
1098+ messageID : z . string ( ) . optional ( ) . describe ( "Parent message ID — creates external ToolPart when present" ) ,
1099+ } ) ,
1100+ ) ,
1101+ async ( c ) => {
1102+ const param = c . req . valid ( "param" )
1103+ const body = c . req . valid ( "json" )
1104+ const session = await Session . get ( param . sessionID )
1105+ const agent = body . agent ?? ( await Agent . defaultAgent ( ) )
1106+ const ag = await Agent . get ( agent )
1107+
1108+ const tools = await ToolRegistry . tools ( {
1109+ providerID : ProviderID . make ( "" ) ,
1110+ modelID : ModelID . make ( "" ) ,
1111+ agent : ag ,
1112+ } )
1113+ const tool = tools . find ( ( t ) => t . id === body . name )
1114+ if ( ! tool ) return c . text ( `Tool not found: ${ body . name } ` , 404 )
1115+
1116+ const t0 = Date . now ( )
1117+ const emit = emitter ( { sessionID : param . sessionID , messageID : body . messageID , tool : body . name } )
1118+
1119+ await emit . fn ( { status : "running" , input : body . args , time : { start : t0 } } )
1120+
1121+ const ctx = {
1122+ sessionID : param . sessionID ,
1123+ messageID : emit . mid ?? MessageID . ascending ( ) ,
1124+ callID : emit . pid ?? PartID . ascending ( ) ,
1125+ agent,
1126+ abort : c . req . raw . signal ,
1127+ messages : [ ] as MessageV2 . WithParts [ ] ,
1128+ metadata ( val : { title ?: string ; metadata ?: Record < string , unknown > } ) {
1129+ emit
1130+ . fn ( {
1131+ status : "running" ,
1132+ input : body . args ,
1133+ title : val . title ,
1134+ metadata : val . metadata ,
1135+ time : { start : t0 } ,
1136+ } )
1137+ ?. catch ( ( ) => { } )
1138+ } ,
1139+ async ask ( req : Omit < Permission . Request , "id" | "sessionID" | "tool" > ) {
1140+ await Permission . ask ( {
1141+ ...req ,
1142+ sessionID : param . sessionID ,
1143+ tool : emit . mid ? { messageID : emit . mid , callID : emit . pid ?? ctx . callID } : undefined ,
1144+ ruleset : Permission . merge ( ag . permission ?? [ ] , session . permission ?? [ ] ) ,
1145+ } )
1146+ } ,
1147+ }
1148+
1149+ c . status ( 200 )
1150+ c . header ( "Content-Type" , "text/plain" )
1151+ return stream ( c , async ( stream ) => {
1152+ try {
1153+ const result = await tool . execute ( body . args , ctx )
1154+ await emit . fn ( {
1155+ status : "completed" ,
1156+ input : body . args ,
1157+ output : result . output ,
1158+ title : result . title ?? "" ,
1159+ metadata : result . metadata ?? { } ,
1160+ time : { start : t0 , end : Date . now ( ) } ,
1161+ } )
1162+ const file = typeof body . args . filePath === "string" ? body . args . filePath : undefined
1163+ await stream . write ( result . output + ( result . attachments ?. length && file ? `\n\x00OC_FILE\x00:${ file } ` : "" ) )
1164+ } catch ( error ) {
1165+ const err = error instanceof Error ? error . message : String ( error )
1166+ await emit . fn ( {
1167+ status : "error" ,
1168+ input : body . args ,
1169+ error : err ,
1170+ time : { start : t0 , end : Date . now ( ) } ,
1171+ } )
1172+ await stream . write ( `Error: ${ err } ` )
1173+ }
1174+ } )
1175+ } ,
1176+ )
1177+ // Display-only status message — creates an external ToolPart for TUI visibility
1178+ . post (
1179+ "/:sessionID/status" ,
1180+ describeRoute ( {
1181+ summary : "Post status message" ,
1182+ description : "Create an external ToolPart for display in the TUI. Not sent to the LLM." ,
1183+ operationId : "session.status.post" ,
1184+ responses : {
1185+ 200 : { description : "Status accepted" , content : { "text/plain" : { schema : resolver ( z . string ( ) ) } } } ,
1186+ ...errors ( 400 , 404 ) ,
1187+ } ,
1188+ } ) ,
1189+ validator ( "param" , z . object ( { sessionID : SessionID . zod } ) ) ,
1190+ validator ( "json" , z . object ( { message : z . string ( ) , messageID : z . string ( ) . optional ( ) } ) ) ,
1191+ async ( c ) => {
1192+ const sessionID = c . req . valid ( "param" ) . sessionID
1193+ const body = c . req . valid ( "json" )
1194+ await Session . get ( sessionID )
1195+ const emit = emitter ( { sessionID, messageID : body . messageID , tool : "status" } )
1196+ if ( emit . mid && body . message ) {
1197+ await emit . fn ( {
1198+ status : "completed" ,
1199+ input : { message : body . message } ,
1200+ output : body . message ,
1201+ title : "" ,
1202+ metadata : { } ,
1203+ time : { start : Date . now ( ) , end : Date . now ( ) } ,
1204+ } )
1205+ }
1206+ return c . text ( "ok" )
1207+ } ,
1208+ )
1209+ // AI judgment via child session — scripts can delegate decisions to the LLM.
1210+ // Each callback gets a fresh context (no token accumulation).
1211+ . post (
1212+ "/:sessionID/exec" ,
1213+ describeRoute ( {
1214+ summary : "Execute AI prompt" ,
1215+ description :
1216+ "Create a child session, send a prompt, and stream the AI response as plain text. Designed for script callbacks that need AI judgment at decision points." ,
1217+ operationId : "session.exec" ,
1218+ responses : {
1219+ 200 : {
1220+ description : "AI response as plain text" ,
1221+ content : { "text/plain" : { schema : resolver ( z . string ( ) ) } } ,
1222+ } ,
1223+ ...errors ( 400 , 404 ) ,
1224+ } ,
1225+ } ) ,
1226+ validator ( "param" , z . object ( { sessionID : SessionID . zod } ) ) ,
1227+ validator (
1228+ "json" ,
1229+ z . object ( {
1230+ prompt : z . string ( ) . describe ( "The prompt text to send to the AI" ) ,
1231+ system : z . string ( ) . optional ( ) . describe ( "Custom system prompt for specialist creation" ) ,
1232+ agent : z . string ( ) . optional ( ) . describe ( "Agent type" ) ,
1233+ model : z . object ( { providerID : ProviderID . zod , modelID : ModelID . zod } ) . optional ( ) . describe ( "Model override" ) ,
1234+ files : z
1235+ . array ( z . object ( { filename : z . string ( ) , mime : z . string ( ) , url : z . string ( ) } ) )
1236+ . optional ( )
1237+ . describe ( "File attachments (PDFs, images) for multimodal prompts" ) ,
1238+ format : z
1239+ . object ( { type : z . literal ( "json_schema" ) , schema : z . record ( z . string ( ) , z . unknown ( ) ) } )
1240+ . optional ( )
1241+ . describe ( "Force structured output via json_schema" ) ,
1242+ messageID : z . string ( ) . optional ( ) . describe ( "Parent message ID — creates external ToolPart when present" ) ,
1243+ } ) ,
1244+ ) ,
1245+ async ( c ) => {
1246+ const parent = c . req . valid ( "param" ) . sessionID
1247+ const body = c . req . valid ( "json" )
1248+ await Session . get ( parent )
1249+
1250+ // Inherit model from parent session if not explicitly provided
1251+ const msgs = body . model ? [ ] : await Session . messages ( { sessionID : parent } )
1252+ const model =
1253+ body . model ??
1254+ msgs . findLast ( ( m ) : m is typeof m & { info : MessageV2 . User } => m . info . role === "user" ) ?. info . model
1255+
1256+ const title = body . system ? `oc prompt -s "${ body . system } "` : "oc prompt"
1257+ const child = await Session . create ( { parentID : parent , title } )
1258+ const cleanup = ( ) => SessionPrompt . cancel ( child . id )
1259+ // Register listener before checking — avoids race where abort fires
1260+ // between the check and addEventListener.
1261+ c . req . raw . signal . addEventListener ( "abort" , cleanup )
1262+ if ( c . req . raw . signal . aborted ) {
1263+ c . req . raw . signal . removeEventListener ( "abort" , cleanup )
1264+ cleanup ( )
1265+ return c . text ( "aborted" , 503 )
1266+ }
1267+
1268+ // Create external ToolPart for subagent visibility (opt-in via messageID)
1269+ const t0 = Date . now ( )
1270+ const preview = body . prompt . substring ( 0 , 80 ) + ( body . prompt . length > 80 ? "..." : "" )
1271+ const emit = emitter ( { sessionID : parent , messageID : body . messageID , tool : "task" } )
1272+
1273+ await emit . fn ( {
1274+ status : "running" ,
1275+ input : { prompt : preview , description : preview , subagent_type : "oc" } ,
1276+ title,
1277+ metadata : { sessionId : child . id , model } ,
1278+ time : { start : t0 } ,
1279+ } )
1280+
1281+ c . status ( 200 )
1282+ c . header ( "Content-Type" , "text/plain" )
1283+ return stream ( c , async ( stream ) => {
1284+ let text = ""
1285+ const unsub =
1286+ emit . mid && emit . pid
1287+ ? Bus . subscribe ( MessageV2 . Event . PartDelta , ( event ) => {
1288+ if ( event . properties . sessionID === child . id && event . properties . field === "text" ) {
1289+ text += event . properties . delta
1290+ if ( text . length > 10_000 ) text = text . slice ( - 10_000 )
1291+ // fire-and-forget — emitter already logs on rejection
1292+ void emit . fn ( {
1293+ status : "running" ,
1294+ input : { prompt : preview } ,
1295+ title,
1296+ metadata : { sessionId : child . id , model, output : text . substring ( 0 , 2000 ) } ,
1297+ time : { start : t0 } ,
1298+ } )
1299+ }
1300+ } )
1301+ : undefined
1302+
1303+ // Keepalive markers prevent HTTP idle timeout for long-running callbacks
1304+ const timer = setInterval ( ( ) => stream . write ( "\x00OC_KEEPALIVE\x00" ) . catch ( ( ) => { } ) , 15_000 )
1305+
1306+ try {
1307+ const msg = await SessionPrompt . prompt ( {
1308+ sessionID : child . id ,
1309+ parts : [
1310+ { type : "text" , text : body . prompt } ,
1311+ ...( body . files ?? [ ] ) . map ( ( f ) => ( {
1312+ type : "file" as const ,
1313+ mime : f . mime ,
1314+ url : f . url ,
1315+ filename : f . filename ,
1316+ } ) ) ,
1317+ ] ,
1318+ system : body . system ,
1319+ agent : body . agent ,
1320+ model,
1321+ format : body . format ? { ...body . format , retryCount : 3 } : undefined ,
1322+ } )
1323+ const out =
1324+ body . format && msg . info . role === "assistant" && msg . info . structured !== undefined
1325+ ? JSON . stringify ( msg . info . structured )
1326+ : ( msg . parts . findLast ( ( p ) : p is typeof p & { type : "text" ; text : string } => p . type === "text" ) ?. text ??
1327+ "" )
1328+
1329+ await emit . fn ( {
1330+ status : "completed" ,
1331+ input : { prompt : preview } ,
1332+ output : out . substring ( 0 , 2000 ) ,
1333+ title,
1334+ metadata : { sessionId : child . id , model } ,
1335+ time : { start : t0 , end : Date . now ( ) } ,
1336+ } )
1337+ await stream . write ( out )
1338+ } catch ( error ) {
1339+ const err = error instanceof Error ? error . message : String ( error )
1340+ await emit . fn ( {
1341+ status : "error" ,
1342+ input : { prompt : preview } ,
1343+ error : err ,
1344+ time : { start : t0 , end : Date . now ( ) } ,
1345+ } )
1346+ await stream . write ( `Error: ${ err } ` )
1347+ } finally {
1348+ clearInterval ( timer )
1349+ unsub ?.( )
1350+ c . req . raw . signal . removeEventListener ( "abort" , cleanup )
1351+ }
1352+ } )
1353+ } ,
1354+ )
1355+ // Todo CRUD — create and bulk update
1356+ . post (
1357+ "/:sessionID/todo" ,
1358+ describeRoute ( {
1359+ summary : "Create session todo" ,
1360+ operationId : "session.todo.create" ,
1361+ responses : {
1362+ 200 : {
1363+ description : "Updated todo list" ,
1364+ content : { "application/json" : { schema : resolver ( Todo . Info . array ( ) ) } } ,
1365+ } ,
1366+ ...errors ( 400 , 404 ) ,
1367+ } ,
1368+ } ) ,
1369+ validator ( "param" , z . object ( { sessionID : SessionID . zod } ) ) ,
1370+ validator ( "json" , Todo . Info ) ,
1371+ async ( c ) => {
1372+ const sessionID = c . req . valid ( "param" ) . sessionID
1373+ await Session . get ( sessionID )
1374+ const todo = c . req . valid ( "json" )
1375+ const existing = await Todo . get ( sessionID )
1376+ const todos = [ ...existing , todo ]
1377+ await Todo . update ( { sessionID, todos } )
1378+ return c . json ( todos )
1379+ } ,
1380+ )
1381+ . put (
1382+ "/:sessionID/todo" ,
1383+ describeRoute ( {
1384+ summary : "Update session todos" ,
1385+ operationId : "session.todo.update" ,
1386+ responses : {
1387+ 200 : {
1388+ description : "Updated todo list" ,
1389+ content : { "application/json" : { schema : resolver ( Todo . Info . array ( ) ) } } ,
1390+ } ,
1391+ ...errors ( 400 , 404 ) ,
1392+ } ,
1393+ } ) ,
1394+ validator ( "param" , z . object ( { sessionID : SessionID . zod } ) ) ,
1395+ validator ( "json" , z . object ( { todos : Todo . Info . array ( ) } ) ) ,
1396+ async ( c ) => {
1397+ const sessionID = c . req . valid ( "param" ) . sessionID
1398+ await Session . get ( sessionID )
1399+ const body = c . req . valid ( "json" )
1400+ await Todo . update ( { sessionID, todos : body . todos } )
1401+ return c . json ( body . todos )
1402+ } ,
10431403 ) ,
10441404)
0 commit comments