task-while 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,7 +40,7 @@ pnpm exec task-while run
40
40
 
41
41
  ## Configuration
42
42
 
43
- `while.yaml` configures the `run` workflow only. When it is absent, the CLI runs `task.source: spec-kit`, `task.maxIterations: 5`, and `workflow.mode: direct` with `codex` for both roles. Each workflow role accepts provider-specific `model` and `effort`.
43
+ `while.yaml` configures the `run` workflow only. When it is absent, the CLI runs `task.source: spec-kit`, `task.maxIterations: 5`, and `workflow.mode: direct` with `codex` for both roles. Each workflow role accepts provider-specific `model`, `effort`, and optional `timeout` in milliseconds.
44
44
 
45
45
  ```yaml
46
46
  task:
@@ -53,20 +53,23 @@ workflow:
53
53
  implementer:
54
54
  model: gpt-5-codex
55
55
  effort: high
56
+ timeout: 900000
56
57
  reviewer:
57
58
  model: gpt-5-codex
58
59
  effort: high
60
+ timeout: 900000
59
61
  ```
60
62
 
61
63
  Current status:
62
64
 
63
65
  - `workflow.roles.<role>.provider` accepts `codex` or `claude`; when omitted it defaults to `codex`, including roles that only set `model` and/or `effort`
66
+ - `workflow.roles.<role>.timeout` is optional and sets a per-turn timeout in milliseconds for local agent runs; valid values are positive integers up to `2147483647`
64
67
  - `codex` `effort` accepts `minimal`, `low`, `medium`, `high`, or `xhigh`
65
68
  - `claude` `effort` accepts `low`, `medium`, `high`, or `max`
66
69
  - `workflow.mode: direct` requires `implementer` and `reviewer` to use identical `model` and `effort` when they share the same provider
67
70
  - `workflow.mode: direct` uses a local reviewer
68
71
  - `workflow.mode: pull-request` pushes a task branch, polls GitHub PR review from `chatgpt-codex-connector[bot]`, then squash-merges on approval
69
- - in `workflow.mode: pull-request`, reviewer `provider` still selects the remote reviewer, but any local reviewer `model` and `effort` values are ignored
72
+ - in `workflow.mode: pull-request`, reviewer `provider` still selects the remote reviewer, but any local reviewer `model`, `effort`, and `timeout` values are ignored
70
73
  - `workflow.mode: pull-request` currently supports only `codex` as the remote reviewer provider
71
74
  - `task.maxIterations` uses the same configured limit for every task in the selected source session; run workflow retries share a single per-task budget across phases
72
75
 
@@ -127,12 +130,15 @@ cd /path/to/workspace
127
130
  pnpm exec task-while batch --config ./batch.yaml
128
131
  ```
129
132
 
133
+ This repository also includes a repo-local skill at `skills/generate-batch-yaml/` for generating batch configs from natural-language requirements.
134
+
130
135
  Batch config example:
131
136
 
132
137
  ```yaml
133
138
  provider: claude
134
139
  model: claude-sonnet-4-6
135
140
  effort: max
141
+ timeout: 300000
136
142
  glob:
137
143
  - 'src/**/*.{ts,tsx}'
138
144
  prompt: |
@@ -155,15 +161,16 @@ Batch behavior:
155
161
  - `glob` is optional and defaults to `**/*`
156
162
  - `glob` is resolved relative to the directory that contains `batch.yaml`
157
163
  - `provider`, `prompt`, and `schema` are required
158
- - `model` and `effort` are optional and are forwarded to the selected provider client
164
+ - `model`, `effort`, and `timeout` are optional and are forwarded to the selected provider client
159
165
  - batch `provider` accepts `codex` or `claude`
160
166
  - batch `codex` `effort` accepts `minimal`, `low`, `medium`, `high`, or `xhigh`
161
167
  - batch `claude` `effort` accepts `low`, `medium`, `high`, or `max`
168
+ - batch `timeout` is an optional per-file timeout in milliseconds; valid values are positive integers up to `2147483647`
162
169
  - each run scans files under the `batch.yaml` directory and filters them by `glob`
163
170
  - structured results are written beside the YAML file in `results.json`
164
171
  - internal harness state is written under `.while/harness/` beside the YAML file
165
172
  - result keys are relative to the directory that contains `batch.yaml`
166
- - `--verbose` streams direct provider details to `stderr` during batch execution, including Claude init/task/tool/result summaries and Codex thinking, commands, MCP tools, file updates, todo changes, messages, and final usage
173
+ - `--verbose` streams batch-level progress and direct provider details to `stderr` during batch execution, including the current file, completion counts, Claude init/task/tool/result summaries, and Codex thinking, commands, MCP tools, file updates, todo changes, messages, and final usage
167
174
  - rerunning the command resumes unfinished work and skips files that already have accepted results
168
175
  - failed files are suspended and retried after all pending files are processed
169
176
  - file-level retries are limited by `maxRetries` (default 3); exhausted files are marked blocked
File without changes
package/package.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "task-while",
3
- "version": "0.0.3",
4
- "packageManager": "pnpm@10.32.1",
3
+ "version": "0.0.5",
5
4
  "description": "Git-first task orchestrator for task-source workspaces",
6
5
  "author": "Zhang Yu",
7
6
  "license": "MIT",
@@ -32,23 +31,9 @@
32
31
  "engines": {
33
32
  "node": ">=24"
34
33
  },
35
- "scripts": {
36
- "dev": "tsx src/index.ts",
37
- "format": "prettier . --write",
38
- "format:check": "prettier . --check",
39
- "lint": "eslint .",
40
- "lint:fix": "eslint . --fix",
41
- "smoke:codex": "tsx fixtures/smoke/codex-provider.ts",
42
- "smoke:e2e:codex": "tsx fixtures/smoke/codex-e2e.ts",
43
- "smoke:github-pr-snapshot": "tsx fixtures/smoke/github-pr-snapshot.ts",
44
- "typecheck": "tsc --noEmit",
45
- "test": "vitest run",
46
- "test:watch": "vitest",
47
- "coverage": "vitest run --coverage"
48
- },
49
34
  "dependencies": {
50
- "@anthropic-ai/claude-agent-sdk": "^0.2.92",
51
- "@openai/codex-sdk": "^0.118.0",
35
+ "@anthropic-ai/claude-agent-sdk": "^0.2.104",
36
+ "@openai/codex-sdk": "^0.120.0",
52
37
  "ajv": "^8.18.0",
53
38
  "arg": "^5.0.2",
54
39
  "execa": "^8.0.1",
@@ -68,5 +53,19 @@
68
53
  "prettier": "^3.8.1",
69
54
  "typescript": "^5.9.3",
70
55
  "vitest": "4.1.0"
56
+ },
57
+ "scripts": {
58
+ "dev": "tsx src/index.ts",
59
+ "format": "prettier . --write",
60
+ "format:check": "prettier . --check",
61
+ "lint": "eslint .",
62
+ "lint:fix": "eslint . --fix",
63
+ "smoke:codex": "tsx fixtures/smoke/codex-provider.ts",
64
+ "smoke:e2e:codex": "tsx fixtures/smoke/codex-e2e.ts",
65
+ "smoke:github-pr-snapshot": "tsx fixtures/smoke/github-pr-snapshot.ts",
66
+ "typecheck": "tsc --noEmit",
67
+ "test": "vitest run",
68
+ "test:watch": "vitest",
69
+ "coverage": "vitest run --coverage"
71
70
  }
72
- }
71
+ }
@@ -6,6 +6,7 @@ import {
6
6
  validateImplementOutput,
7
7
  validateReviewOutput,
8
8
  } from '../schema/index'
9
+ import { withAbortTimeout } from './timeout'
9
10
 
10
11
  import type { Options as ClaudeQueryOptions } from '@anthropic-ai/claude-agent-sdk'
11
12
 
@@ -278,34 +279,41 @@ export class ClaudeAgentClient
278
279
 
279
280
  public async invokeStructured<T>(input: ClaudeStructuredInput): Promise<T> {
280
281
  const { query } = await import('@anthropic-ai/claude-agent-sdk')
281
- const queryOptions = {
282
- allowDangerouslySkipPermissions: true,
283
- cwd: this.options.workspaceRoot,
284
- permissionMode: 'bypassPermissions',
285
- outputFormat: {
286
- schema: input.outputSchema,
287
- type: 'json_schema',
288
- },
289
- ...(this.options.onEvent
290
- ? {
291
- agentProgressSummaries: true,
292
- includePartialMessages: true,
293
- }
294
- : {
295
- includePartialMessages: false,
296
- }),
297
- ...(this.options.model ? { model: this.options.model } : {}),
298
- ...(this.options.effort ? { effort: this.options.effort } : {}),
299
- } satisfies ClaudeQueryOptions
300
-
301
- const messages = query({
302
- options: queryOptions,
303
- prompt: input.prompt,
304
- })
282
+ return withAbortTimeout(
283
+ this.name,
284
+ this.options.timeout,
285
+ async (controller) => {
286
+ const queryOptions = {
287
+ allowDangerouslySkipPermissions: true,
288
+ cwd: this.options.workspaceRoot,
289
+ permissionMode: 'bypassPermissions',
290
+ outputFormat: {
291
+ schema: input.outputSchema,
292
+ type: 'json_schema',
293
+ },
294
+ ...(this.options.onEvent
295
+ ? {
296
+ agentProgressSummaries: true,
297
+ includePartialMessages: true,
298
+ }
299
+ : {
300
+ includePartialMessages: false,
301
+ }),
302
+ ...(this.options.model ? { model: this.options.model } : {}),
303
+ ...(this.options.effort ? { effort: this.options.effort } : {}),
304
+ ...(controller ? { abortController: controller } : {}),
305
+ } satisfies ClaudeQueryOptions
306
+
307
+ const messages = query({
308
+ options: queryOptions,
309
+ prompt: input.prompt,
310
+ })
305
311
 
306
- return this.collectStructuredOutput(
307
- messages as AsyncIterable<QueryMessage>,
308
- ) as Promise<T>
312
+ return this.collectStructuredOutput(
313
+ messages as AsyncIterable<QueryMessage>,
314
+ ) as Promise<T>
315
+ },
316
+ )
309
317
  }
310
318
 
311
319
  public async review(input: ReviewAgentInput) {
@@ -6,6 +6,7 @@ import {
6
6
  validateImplementOutput,
7
7
  validateReviewOutput,
8
8
  } from '../schema/index'
9
+ import { withAbortTimeout } from './timeout'
9
10
 
10
11
  import type { CodexProviderOptions } from './provider-options'
11
12
  import type {
@@ -143,6 +144,7 @@ export interface CodexRunStreamedResult {
143
144
 
144
145
  export interface CodexThreadRunOptions {
145
146
  outputSchema: Record<string, unknown>
147
+ signal?: AbortSignal
146
148
  }
147
149
 
148
150
  export interface CodexThreadLike {
@@ -190,9 +192,11 @@ export class CodexAgentClient implements ImplementerProvider, ReviewerProvider {
190
192
  private async collectStreamedTurn<T>(
191
193
  thread: CodexThreadLike,
192
194
  input: CodexStructuredInput,
195
+ signal?: AbortSignal,
193
196
  ): Promise<T> {
194
197
  const streamedTurn = await thread.runStreamed!(input.prompt, {
195
198
  outputSchema: input.outputSchema,
199
+ ...(signal ? { signal } : {}),
196
200
  })
197
201
  let finalResponse = ''
198
202
 
@@ -259,23 +263,34 @@ export class CodexAgentClient implements ImplementerProvider, ReviewerProvider {
259
263
  const thread = client.startThread(startThreadOptions)
260
264
 
261
265
  if (this.options.onEvent && thread.runStreamed) {
262
- return this.collectStreamedTurn<T>(thread, input)
263
- }
264
-
265
- const turn = await thread.run(input.prompt, {
266
- outputSchema: input.outputSchema,
267
- })
268
- const response = turn.finalResponse.trim()
269
- if (!response) {
270
- throw new Error('Codex agent client returned empty finalResponse')
271
- }
272
- try {
273
- return JSON.parse(response) as T
274
- } catch (error) {
275
- throw new Error('Codex agent client returned non-JSON finalResponse', {
276
- cause: error,
277
- })
266
+ return withAbortTimeout(this.name, this.options.timeout, (controller) =>
267
+ this.collectStreamedTurn<T>(thread, input, controller?.signal),
268
+ )
278
269
  }
270
+ return withAbortTimeout(
271
+ this.name,
272
+ this.options.timeout,
273
+ async (controller) => {
274
+ const turn = await thread.run(input.prompt, {
275
+ outputSchema: input.outputSchema,
276
+ ...(controller ? { signal: controller.signal } : {}),
277
+ })
278
+ const response = turn.finalResponse.trim()
279
+ if (!response) {
280
+ throw new Error('Codex agent client returned empty finalResponse')
281
+ }
282
+ try {
283
+ return JSON.parse(response) as T
284
+ } catch (error) {
285
+ throw new Error(
286
+ 'Codex agent client returned non-JSON finalResponse',
287
+ {
288
+ cause: error,
289
+ },
290
+ )
291
+ }
292
+ },
293
+ )
279
294
  }
280
295
 
281
296
  public async review(input: ReviewAgentInput) {
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod'
2
2
 
3
3
  const modelSchema = z.string().trim().min(1)
4
+ const timeoutSchema = z.number().int().positive().max(2_147_483_647)
4
5
 
5
6
  export const codexEffortSchema = z.enum([
6
7
  'minimal',
@@ -16,6 +17,7 @@ export const codexProviderOptionsSchema = z
16
17
  .object({
17
18
  effort: codexEffortSchema.optional(),
18
19
  model: modelSchema.optional(),
20
+ timeout: timeoutSchema.optional(),
19
21
  })
20
22
  .strict()
21
23
 
@@ -23,6 +25,7 @@ export const claudeProviderOptionsSchema = z
23
25
  .object({
24
26
  effort: claudeEffortSchema.optional(),
25
27
  model: modelSchema.optional(),
28
+ timeout: timeoutSchema.optional(),
26
29
  })
27
30
  .strict()
28
31
 
@@ -43,3 +46,17 @@ export function providerOptionsEqual(
43
46
  left.effort === right.effort
44
47
  )
45
48
  }
49
+
50
+ export function providerOptionsCacheKey(options: {
51
+ effort?: string | undefined
52
+ model?: string | undefined
53
+ provider: 'claude' | 'codex'
54
+ timeout?: number | undefined
55
+ }) {
56
+ return [
57
+ options.provider,
58
+ options.model ?? '',
59
+ options.effort ?? '',
60
+ String(options.timeout ?? ''),
61
+ ].join(':')
62
+ }
@@ -0,0 +1,41 @@
1
+ export class AgentTimeoutError extends Error {
2
+ public constructor(
3
+ public readonly provider: string,
4
+ public readonly timeout: number,
5
+ options?: ErrorOptions,
6
+ ) {
7
+ super(`${provider} agent timed out after ${timeout}ms`, options)
8
+ }
9
+ }
10
+
11
+ export async function withAbortTimeout<T>(
12
+ provider: string,
13
+ timeout: number | undefined,
14
+ run: (controller: AbortController | undefined) => Promise<T>,
15
+ ): Promise<T> {
16
+ if (!timeout) {
17
+ return run(undefined)
18
+ }
19
+
20
+ const controller = new AbortController()
21
+ const state = { timedOut: false }
22
+ const timer = setTimeout(() => {
23
+ state.timedOut = true
24
+ controller.abort()
25
+ }, timeout)
26
+
27
+ try {
28
+ const result = await run(controller)
29
+ if (state.timedOut) {
30
+ throw new AgentTimeoutError(provider, timeout)
31
+ }
32
+ return result
33
+ } catch (error) {
34
+ if (state.timedOut) {
35
+ throw new AgentTimeoutError(provider, timeout, { cause: error })
36
+ }
37
+ throw error
38
+ } finally {
39
+ clearTimeout(timer)
40
+ }
41
+ }
@@ -97,6 +97,7 @@ export async function loadBatchConfig(
97
97
  provider: 'claude',
98
98
  ...(parsedConfig.model ? { model: parsedConfig.model } : {}),
99
99
  ...(parsedConfig.effort ? { effort: parsedConfig.effort } : {}),
100
+ ...(parsedConfig.timeout ? { timeout: parsedConfig.timeout } : {}),
100
101
  }
101
102
  }
102
103
 
@@ -105,5 +106,6 @@ export async function loadBatchConfig(
105
106
  provider: 'codex',
106
107
  ...(parsedConfig.model ? { model: parsedConfig.model } : {}),
107
108
  ...(parsedConfig.effort ? { effort: parsedConfig.effort } : {}),
109
+ ...(parsedConfig.timeout ? { timeout: parsedConfig.timeout } : {}),
108
110
  }
109
111
  }
@@ -70,6 +70,7 @@ export function createBatchStructuredOutputProvider(
70
70
  new CodexAgentClient({
71
71
  ...(input.effort ? { effort: input.effort } : {}),
72
72
  ...(input.model ? { model: input.model } : {}),
73
+ ...(input.timeout ? { timeout: input.timeout } : {}),
73
74
  ...(onEvent ? { onEvent } : {}),
74
75
  workspaceRoot: input.workspaceRoot,
75
76
  }),
@@ -81,6 +82,7 @@ export function createBatchStructuredOutputProvider(
81
82
  new ClaudeAgentClient({
82
83
  ...(input.effort ? { effort: input.effort } : {}),
83
84
  ...(input.model ? { model: input.model } : {}),
85
+ ...(input.timeout ? { timeout: input.timeout } : {}),
84
86
  ...(onEvent ? { onEvent } : {}),
85
87
  workspaceRoot: input.workspaceRoot,
86
88
  }),
@@ -42,6 +42,38 @@ export interface RunBatchCommandResult {
42
42
  resultsFilePath: string
43
43
  }
44
44
 
45
+ function writeBatchVerboseLine(verbose: boolean | undefined, line: string) {
46
+ if (!verbose) {
47
+ return
48
+ }
49
+ process.stderr.write(`[batch] ${line}\n`)
50
+ }
51
+
52
+ function readSessionProgress(detail: unknown) {
53
+ if (typeof detail !== 'object' || detail === null) {
54
+ return null
55
+ }
56
+ const progress = (detail as { progress?: unknown }).progress
57
+ if (typeof progress !== 'object' || progress === null) {
58
+ return null
59
+ }
60
+
61
+ const blocked =
62
+ typeof (progress as { blocked?: unknown }).blocked === 'number'
63
+ ? (progress as { blocked: number }).blocked
64
+ : 0
65
+ const completed =
66
+ typeof (progress as { completed?: unknown }).completed === 'number'
67
+ ? (progress as { completed: number }).completed
68
+ : 0
69
+ const suspended =
70
+ typeof (progress as { suspended?: unknown }).suspended === 'number'
71
+ ? (progress as { suspended: number }).suspended
72
+ : 0
73
+
74
+ return { blocked, completed, suspended }
75
+ }
76
+
45
77
  async function readJsonFileIfExists(filePath: string) {
46
78
  const exists = await fsExtra.pathExists(filePath)
47
79
  if (!exists) {
@@ -69,6 +101,7 @@ function createProvider(
69
101
  provider: 'codex',
70
102
  ...(config.effort ? { effort: config.effort } : {}),
71
103
  ...(config.model ? { model: config.model } : {}),
104
+ ...(config.timeout ? { timeout: config.timeout } : {}),
72
105
  ...(verbose === undefined ? {} : { verbose }),
73
106
  workspaceRoot: config.configDir,
74
107
  })
@@ -78,6 +111,7 @@ function createProvider(
78
111
  provider: 'claude',
79
112
  ...(config.effort ? { effort: config.effort } : {}),
80
113
  ...(config.model ? { model: config.model } : {}),
114
+ ...(config.timeout ? { timeout: config.timeout } : {}),
81
115
  ...(verbose === undefined ? {} : { verbose }),
82
116
  workspaceRoot: config.configDir,
83
117
  })
@@ -138,6 +172,10 @@ export async function runBatchCommand(
138
172
  })
139
173
 
140
174
  const processedFiles: string[] = []
175
+ const totalFiles = discoveredFiles.length
176
+ let blockedCount = 0
177
+ let completedCount = 0
178
+ let suspendedCount = 0
141
179
 
142
180
  for await (const event of runSession({
143
181
  config: {},
@@ -153,8 +191,70 @@ export async function runBatchCommand(
153
191
  }),
154
192
  },
155
193
  })) {
194
+ if (event.type === SessionEventType.SessionStarted) {
195
+ const progress = readSessionProgress(event.detail)
196
+ if (progress) {
197
+ blockedCount = progress.blocked
198
+ completedCount = progress.completed
199
+ suspendedCount = progress.suspended
200
+ }
201
+ writeBatchVerboseLine(
202
+ input.verbose,
203
+ `resume total=${totalFiles} completed=${completedCount} blocked=${blockedCount} suspended=${suspendedCount}`,
204
+ )
205
+ continue
206
+ }
207
+
208
+ if (event.type === SessionEventType.SubjectStarted) {
209
+ writeBatchVerboseLine(
210
+ input.verbose,
211
+ `start completed=${completedCount}/${totalFiles} file=${event.subjectId}`,
212
+ )
213
+ continue
214
+ }
215
+
216
+ if (event.type === SessionEventType.SubjectResumed) {
217
+ suspendedCount = Math.max(0, suspendedCount - 1)
218
+ writeBatchVerboseLine(
219
+ input.verbose,
220
+ `resume-file completed=${completedCount}/${totalFiles} file=${event.subjectId}`,
221
+ )
222
+ continue
223
+ }
224
+
156
225
  if (event.type === SessionEventType.SubjectDone) {
226
+ completedCount += 1
157
227
  processedFiles.push(event.subjectId)
228
+ writeBatchVerboseLine(
229
+ input.verbose,
230
+ `done completed=${completedCount}/${totalFiles} file=${event.subjectId}`,
231
+ )
232
+ continue
233
+ }
234
+
235
+ if (event.type === SessionEventType.SubjectBlocked) {
236
+ blockedCount += 1
237
+ writeBatchVerboseLine(
238
+ input.verbose,
239
+ `blocked completed=${completedCount}/${totalFiles} file=${event.subjectId}`,
240
+ )
241
+ continue
242
+ }
243
+
244
+ if (event.type === SessionEventType.SubjectSuspended) {
245
+ suspendedCount += 1
246
+ writeBatchVerboseLine(
247
+ input.verbose,
248
+ `suspended completed=${completedCount}/${totalFiles} file=${event.subjectId}`,
249
+ )
250
+ continue
251
+ }
252
+
253
+ if (event.type === SessionEventType.SessionDone) {
254
+ writeBatchVerboseLine(
255
+ input.verbose,
256
+ `session-done total=${totalFiles} completed=${completedCount} blocked=${blockedCount} suspended=${suspendedCount}`,
257
+ )
158
258
  }
159
259
  }
160
260
 
@@ -4,6 +4,7 @@ import {
4
4
  createClaudeEventHandler,
5
5
  createCodexEventHandler,
6
6
  } from '../agents/event-log'
7
+ import { providerOptionsCacheKey } from '../agents/provider-options'
7
8
  import { createCodexRemoteReviewerProvider } from '../workflow/remote-reviewer'
8
9
 
9
10
  import type {
@@ -26,12 +27,10 @@ export function createProviderResolver(
26
27
  context: WorkspaceContext,
27
28
  verbose: boolean | undefined,
28
29
  ): ProviderResolver {
29
- const cache = new Map<
30
- WorkflowProvider,
31
- ImplementerProvider & ReviewerProvider
32
- >()
30
+ const cache = new Map<string, ImplementerProvider & ReviewerProvider>()
33
31
  return (role: WorkflowRoleConfig) => {
34
- const cached = cache.get(role.provider)
32
+ const cacheKey = providerOptionsCacheKey(role)
33
+ const cached = cache.get(cacheKey)
35
34
  if (cached) {
36
35
  return cached
37
36
  }
@@ -41,6 +40,7 @@ export function createProviderResolver(
41
40
  provider = createClaudeProvider({
42
41
  ...(role.effort ? { effort: role.effort } : {}),
43
42
  ...(role.model ? { model: role.model } : {}),
43
+ ...(role.timeout ? { timeout: role.timeout } : {}),
44
44
  workspaceRoot: context.workspaceRoot,
45
45
  ...(onEvent ? { onEvent } : {}),
46
46
  })
@@ -49,11 +49,12 @@ export function createProviderResolver(
49
49
  provider = createCodexProvider({
50
50
  ...(role.effort ? { effort: role.effort } : {}),
51
51
  ...(role.model ? { model: role.model } : {}),
52
+ ...(role.timeout ? { timeout: role.timeout } : {}),
52
53
  workspaceRoot: context.workspaceRoot,
53
54
  ...(onEvent ? { onEvent } : {}),
54
55
  })
55
56
  }
56
- cache.set(role.provider, provider)
57
+ cache.set(cacheKey, provider)
57
58
  return provider
58
59
  }
59
60
  }
@@ -10,6 +10,7 @@ import {
10
10
  createClaudeEventHandler,
11
11
  createCodexEventHandler,
12
12
  } from '../agents/event-log'
13
+ import { providerOptionsCacheKey } from '../agents/provider-options'
13
14
  import { GitRuntime } from '../runtime/git'
14
15
  import { GitHubRuntime } from '../runtime/github'
15
16
  import { createRuntimePaths } from '../runtime/path-layout'
@@ -54,7 +55,7 @@ export function createRuntimePorts(
54
55
  const agentCache = new Map<string, AgentPort>()
55
56
 
56
57
  function resolveAgent(role: AgentRoleConfig): AgentPort {
57
- const key = `${role.provider}:${role.model ?? ''}:${role.effort ?? ''}`
58
+ const key = providerOptionsCacheKey(role)
58
59
  const cached = agentCache.get(key)
59
60
  if (cached) {
60
61
  return cached
@@ -72,6 +73,7 @@ export function createRuntimePorts(
72
73
  }
73
74
  : {}),
74
75
  ...(role.model ? { model: role.model } : {}),
76
+ ...(role.timeout ? { timeout: role.timeout } : {}),
75
77
  workspaceRoot: context.workspaceRoot,
76
78
  ...(onEvent ? { onEvent } : {}),
77
79
  })
@@ -86,6 +88,7 @@ export function createRuntimePorts(
86
88
  }
87
89
  : {}),
88
90
  ...(role.model ? { model: role.model } : {}),
91
+ ...(role.timeout ? { timeout: role.timeout } : {}),
89
92
  workspaceRoot: context.workspaceRoot,
90
93
  ...(onEvent ? { onEvent } : {}),
91
94
  })
@@ -138,6 +138,7 @@ export interface AgentRoleConfig {
138
138
  effort?: string | undefined
139
139
  model?: string | undefined
140
140
  provider: 'claude' | 'codex'
141
+ timeout?: number | undefined
141
142
  }
142
143
 
143
144
  export interface OrchestratorRuntime {