Skip to content

Commit edf4e63

Browse files
committed
feat(cli): add direct headless extension API
1 parent 4a80564 commit edf4e63

7 files changed

Lines changed: 522 additions & 24 deletions

File tree

packages/types/src/api.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,64 @@ import type { Socket } from "net"
44
import type { RooCodeEvents } from "./events.js"
55
import type { RooCodeSettings } from "./global-settings.js"
66
import type { HistoryItem } from "./history.js"
7+
import type { TokenUsage } from "./message.js"
8+
import type { ToolUsage } from "./tool.js"
79
import type { ProviderSettingsEntry, ProviderSettings } from "./provider-settings.js"
810
import type { IpcMessage, IpcServerEvents } from "./ipc.js"
911

1012
export type RooCodeAPIEvents = RooCodeEvents
1113

14+
export type HeadlessCapabilities = {
15+
checkpoints: false
16+
typedAsks: true
17+
rootTaskResults: true
18+
}
19+
20+
export type HeadlessTaskReference = { taskId: string; rootTaskId: string }
21+
22+
export type HeadlessAskResponse =
23+
| { response: "approve" }
24+
| { response: "reject" }
25+
| { response: "message"; text: string; images?: string[] }
26+
27+
export type HeadlessCancelSettlement = {
28+
rootTaskId: string
29+
resumable: boolean
30+
status: "interrupted" | "failed"
31+
}
32+
33+
export type HeadlessTaskResult = {
34+
rootTaskId: string
35+
currentTaskId: string
36+
outcome: "completed" | "cancelled" | "failed"
37+
resumable: boolean
38+
content?: string
39+
error?: { code: "task_failed" | "cancel_failed" | "shutdown"; message: string }
40+
tokenUsage?: TokenUsage
41+
toolUsage?: ToolUsage
42+
}
43+
44+
export type HeadlessShutdownReport = {
45+
settledRuns: number
46+
pendingRuns: number
47+
}
48+
1249
export interface RooCodeAPI extends EventEmitter<RooCodeAPIEvents> {
50+
initializeHeadless(): Promise<HeadlessCapabilities>
51+
startHeadlessTask(input: {
52+
text: string
53+
images?: string[]
54+
configuration?: RooCodeSettings
55+
}): Promise<HeadlessTaskReference>
56+
resumeHeadlessTask(taskId: string): Promise<HeadlessTaskReference>
57+
respondToHeadlessAsk(input: { taskId: string; askId: string; response: HeadlessAskResponse }): Promise<void>
58+
cancelHeadlessTask(input: {
59+
rootTaskId: string
60+
reason: "user" | "signal" | "timeout"
61+
}): Promise<HeadlessCancelSettlement>
62+
getHeadlessTaskResult(rootTaskId: string): Promise<HeadlessTaskResult | undefined>
63+
waitForHeadlessTaskResult(rootTaskId: string): Promise<HeadlessTaskResult>
64+
shutdownHeadless(): Promise<HeadlessShutdownReport>
1365
/**
1466
* Starts a new task with an optional initial message and images.
1567
* @param task Optional initial task message.

packages/types/src/events.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { z } from "zod"
33
import { clineMessageSchema, queuedMessageSchema, tokenUsageSchema } from "./message.js"
44
import { modelInfoSchema } from "./model.js"
55
import { toolNamesSchema, toolUsageSchema } from "./tool.js"
6+
import { historyItemSchema } from "./history.js"
67

78
/**
89
* RooCodeEventName
@@ -50,6 +51,11 @@ export enum RooCodeEventName {
5051
CommandsResponse = "commandsResponse",
5152
ModesResponse = "modesResponse",
5253
ModelsResponse = "modelsResponse",
54+
55+
// Direct headless API
56+
HeadlessAsk = "headlessAsk",
57+
HeadlessTerminalFailure = "headlessTerminalFailure",
58+
HeadlessTaskResult = "headlessTaskResult",
5359
}
5460

5561
/**
@@ -124,6 +130,29 @@ export const rooCodeEventsSchema = z.object({
124130
]),
125131
[RooCodeEventName.ModesResponse]: z.tuple([z.array(z.object({ slug: z.string(), name: z.string() }))]),
126132
[RooCodeEventName.ModelsResponse]: z.tuple([z.record(z.string(), modelInfoSchema)]),
133+
[RooCodeEventName.HeadlessAsk]: z.tuple([
134+
z.object({
135+
taskId: z.string(),
136+
rootTaskId: z.string(),
137+
askId: z.string(),
138+
ask: z.string(),
139+
text: z.string().optional(),
140+
isProtected: z.boolean().optional(),
141+
}),
142+
]),
143+
[RooCodeEventName.HeadlessTerminalFailure]: z.tuple([
144+
z.object({ taskId: z.string(), rootTaskId: z.string(), code: z.string(), message: z.string() }),
145+
]),
146+
[RooCodeEventName.HeadlessTaskResult]: z.tuple([
147+
z.object({
148+
rootTaskId: z.string(),
149+
currentTaskId: z.string(),
150+
outcome: z.enum(["completed", "cancelled", "failed"]),
151+
resumable: z.boolean(),
152+
content: z.string().optional(),
153+
historyItem: historyItemSchema.optional(),
154+
}),
155+
]),
127156
})
128157

129158
export type RooCodeEvents = z.infer<typeof rooCodeEventsSchema>
@@ -269,6 +298,18 @@ export const taskEventSchema = z.discriminatedUnion("eventName", [
269298
payload: rooCodeEventsSchema.shape[RooCodeEventName.ModelsResponse],
270299
taskId: z.number().optional(),
271300
}),
301+
z.object({
302+
eventName: z.literal(RooCodeEventName.HeadlessAsk),
303+
payload: rooCodeEventsSchema.shape[RooCodeEventName.HeadlessAsk],
304+
}),
305+
z.object({
306+
eventName: z.literal(RooCodeEventName.HeadlessTerminalFailure),
307+
payload: rooCodeEventsSchema.shape[RooCodeEventName.HeadlessTerminalFailure],
308+
}),
309+
z.object({
310+
eventName: z.literal(RooCodeEventName.HeadlessTaskResult),
311+
payload: rooCodeEventsSchema.shape[RooCodeEventName.HeadlessTaskResult],
312+
}),
272313
])
273314

274315
export type TaskEvent = z.infer<typeof taskEventSchema>

src/core/task/Task.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1265,6 +1265,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
12651265
// So in this case we must make sure that the message ts is
12661266
// never altered after first setting it.
12671267
askTs = lastMessage.ts
1268+
this.pendingHeadlessAskId = askTs
12681269
this.lastMessageTs = askTs
12691270
lastMessage.text = text
12701271
lastMessage.partial = false
@@ -1286,6 +1287,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
12861287
this.askResponseText = undefined
12871288
this.askResponseImages = undefined
12881289
askTs = Date.now()
1290+
this.pendingHeadlessAskId = askTs
12891291
this.lastMessageTs = askTs
12901292
await this.addToClineMessages({
12911293
ts: askTs,
@@ -1304,6 +1306,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
13041306
this.askResponseText = undefined
13051307
this.askResponseImages = undefined
13061308
askTs = Date.now()
1309+
this.pendingHeadlessAskId = askTs
13071310
this.lastMessageTs = askTs
13081311
await this.addToClineMessages({
13091312
ts: askTs,
@@ -1316,6 +1319,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
13161319
})
13171320
}
13181321

1322+
this.pendingHeadlessAskId = askTs
13191323
const timeouts: NodeJS.Timeout[] = []
13201324

13211325
if (approval.decision === "approve") {
@@ -1429,10 +1433,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
14291433

14301434
/* v8 ignore next 3 -- abort-while-waiting path; covered by e2e standalone-resume test */
14311435
if (this.abort) {
1436+
timeouts.forEach((timeout) => clearTimeout(timeout))
1437+
if (this.pendingHeadlessAskId === askTs) this.pendingHeadlessAskId = undefined
14321438
throw new Error(`[ZooCode#ask] task ${this.taskId}.${this.instanceId} aborted`)
14331439
}
14341440

14351441
if (this.lastMessageTs !== askTs) {
1442+
timeouts.forEach((timeout) => clearTimeout(timeout))
1443+
if (this.pendingHeadlessAskId === askTs) this.pendingHeadlessAskId = undefined
14361444
// Could happen if we send multiple asks in a row i.e. with
14371445
// command_output. It's important that when we know an ask could
14381446
// fail, it is handled gracefully.
@@ -1443,6 +1451,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
14431451
this.askResponse = undefined
14441452
this.askResponseText = undefined
14451453
this.askResponseImages = undefined
1454+
if (this.pendingHeadlessAskId === askTs) this.pendingHeadlessAskId = undefined
14461455

14471456
// Cancel the timeouts if they are still running.
14481457
timeouts.forEach((timeout) => clearTimeout(timeout))
@@ -1510,6 +1519,19 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
15101519
}
15111520
}
15121521

1522+
private pendingHeadlessAskId: number | undefined
1523+
1524+
public get pendingAskId(): number | undefined {
1525+
return this.pendingHeadlessAskId
1526+
}
1527+
1528+
public respondToAsk(askId: number, askResponse: ClineAskResponse, text?: string, images?: string[]): boolean {
1529+
if (this.abort || this.pendingHeadlessAskId !== askId) return false
1530+
this.pendingHeadlessAskId = undefined
1531+
this.handleWebviewAskResponse(askResponse, text, images)
1532+
return true
1533+
}
1534+
15131535
/**
15141536
* Cancel any pending auto-approval timeout.
15151537
* Called when user interacts (types, clicks buttons, etc.) to prevent the timeout from firing.

src/core/webview/ClineProvider.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,8 @@ export class ClineProvider
247247
this.scheduleGlobalStateWriteThrough()
248248
},
249249
})
250-
this.initializeTaskHistoryStore().catch((error) => {
250+
this.taskHistoryInitialization = this.initializeTaskHistoryStore()
251+
this.taskHistoryInitialization.catch((error) => {
251252
this.log(`Failed to initialize TaskHistoryStore: ${error}`)
252253
})
253254

@@ -3054,6 +3055,16 @@ export class ClineProvider
30543055
return this.taskRegistry.current
30553056
}
30563057

3058+
private readonly taskHistoryInitialization: Promise<void>
3059+
3060+
public waitUntilReady(): Promise<void> {
3061+
return this.taskHistoryInitialization
3062+
}
3063+
3064+
public getTaskById(taskId: string): Task | undefined {
3065+
return this.taskRegistry.getById(taskId)
3066+
}
3067+
30573068
private logWebviewHiddenDiagnostics(): void {
30583069
const task = this.getCurrentTask()
30593070
if (!task || task.abort || task.abandoned) {
@@ -3221,18 +3232,18 @@ export class ClineProvider
32213232
return task
32223233
}
32233234

3224-
public async cancelTask(): Promise<void> {
3235+
public async cancelTask(options: { rehydrate?: boolean } = {}): Promise<void> {
32253236
const task = this.getCurrentTask()
32263237

32273238
if (!task) {
32283239
return
32293240
}
32303241

32313242
console.log(`[cancelTask] cancelling task ${task.taskId}.${task.instanceId}`)
3232-
await this.cancelTaskInternal(task)
3243+
await this.cancelTaskInternal(task, options)
32333244
}
32343245

3235-
private async cancelTaskInternal(task: Task): Promise<void> {
3246+
private async cancelTaskInternal(task: Task, options: { rehydrate?: boolean }): Promise<void> {
32363247
let historyItem: HistoryItem | undefined
32373248
try {
32383249
const history = await this.getTaskWithId(task.taskId)
@@ -3315,6 +3326,11 @@ export class ClineProvider
33153326
return
33163327
}
33173328

3329+
if (!task.parentTaskId && historyItem.status !== "interrupted") {
3330+
historyItem = { ...historyItem, status: "interrupted" }
3331+
await this.updateTaskHistory(historyItem)
3332+
}
3333+
33183334
if (task.parentTaskId) {
33193335
try {
33203336
await this.runDelegationTransition(task.parentTaskId, async () => {
@@ -3362,6 +3378,10 @@ export class ClineProvider
33623378
}
33633379
}
33643380

3381+
if (options.rehydrate === false) {
3382+
return
3383+
}
3384+
33653385
// Clears task again, so we need to abortTask manually above.
33663386
await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask })
33673387
}

src/eslint-suppressions.json

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,11 +1184,6 @@
11841184
"count": 7
11851185
}
11861186
},
1187-
"extension/api.ts": {
1188-
"@typescript-eslint/no-explicit-any": {
1189-
"count": 9
1190-
}
1191-
},
11921187
"i18n/index.ts": {
11931188
"@typescript-eslint/no-explicit-any": {
11941189
"count": 1

0 commit comments

Comments
 (0)