diff --git a/src/core/prompts/tools/native-tools/new_task.ts b/src/core/prompts/tools/native-tools/new_task.ts index f846eaf73a..42da8faf4a 100644 --- a/src/core/prompts/tools/native-tools/new_task.ts +++ b/src/core/prompts/tools/native-tools/new_task.ts @@ -44,12 +44,13 @@ export default { permissions: { type: ["string", "null"], description: PERMISSIONS_PARAMETER_DESCRIPTION, + }, background: { type: ["string", "null"], description: BACKGROUND_PARAMETER_DESCRIPTION, }, }, - required: ["mode", "message", "todos"], + required: ["mode", "message", "todos", "task_queue", "permissions", "background"], additionalProperties: false, }, }, diff --git a/src/services/file-lock/FileLockManager.ts b/src/services/file-lock/FileLockManager.ts new file mode 100644 index 0000000000..22ea20bbdf --- /dev/null +++ b/src/services/file-lock/FileLockManager.ts @@ -0,0 +1,348 @@ +import path from "path" + +/** + * Information about a file lock held by a task. + */ +export interface FileLockInfo { + /** Absolute normalized path of the locked file */ + filePath: string + /** ID of the task holding the lock */ + taskId: string + /** Timestamp (ms) when the lock was acquired */ + acquiredAt: number +} + +/** + * Result returned when a lock acquisition attempt fails. + */ +export interface LockConflict { + /** The file that is already locked */ + filePath: string + /** The task that currently holds the lock */ + holdingTaskId: string + /** How long (ms) the lock has been held */ + heldForMs: number +} + +/** + * Events emitted by the FileLockManager. + */ +export type FileLockEvent = + | { type: "lock-acquired"; filePath: string; taskId: string } + | { type: "lock-released"; filePath: string; taskId: string } + | { type: "lock-expired"; filePath: string; taskId: string } + | { type: "all-locks-released"; taskId: string; count: number } + +export type FileLockEventListener = (event: FileLockEvent) => void + +export interface FileLockManagerOptions { + /** + * Maximum duration (ms) a lock can be held before it is forcibly released. + * Default: 120_000 (2 minutes). + */ + lockTimeoutMs?: number +} + +const DEFAULT_LOCK_TIMEOUT_MS = 120_000 + +/** + * Advisory file-level lock manager for coordinating writes across concurrent tasks. + * + * Locks are "advisory" -- they do not use OS-level file locks. Instead, the + * tool execution layer checks the lock manager before allowing write operations. + * This keeps the system portable and testable. + * + * All file paths are normalized to absolute paths using `path.resolve` before + * being used as map keys, ensuring consistent lookup regardless of how the + * path is specified (relative, absolute, trailing slashes, etc.). + */ +export class FileLockManager { + /** + * Map from normalized absolute file path to lock info. + */ + private locks = new Map() + + /** + * Reverse index: taskId -> set of normalized file paths locked by that task. + */ + private taskLocks = new Map>() + + /** + * Event listeners. + */ + private listeners: FileLockEventListener[] = [] + + /** + * Maximum lock hold duration in milliseconds. + */ + private readonly lockTimeoutMs: number + + constructor(options?: FileLockManagerOptions) { + this.lockTimeoutMs = options?.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS + } + + /** + * Attempt to acquire a write lock on a file for a specific task. + * + * If the file is already locked by the same task, refreshes the timestamp + * and returns true (re-entrant). If locked by a different task, checks + * for expiration first -- if the existing lock has expired it is forcibly + * released before granting the new lock. + * + * @returns `true` if the lock was acquired, `false` if another task holds it. + */ + acquireLock(filePath: string, taskId: string): boolean { + const normalized = this.normalizePath(filePath) + const existing = this.locks.get(normalized) + + if (existing) { + // Re-entrant: same task already holds the lock -- refresh timestamp + if (existing.taskId === taskId) { + existing.acquiredAt = Date.now() + return true + } + + // Check if the existing lock has expired + if (this.isLockExpired(existing)) { + this.forceReleaseLock(normalized, existing.taskId) + } else { + return false + } + } + + // Acquire the lock + const lockInfo: FileLockInfo = { + filePath: normalized, + taskId, + acquiredAt: Date.now(), + } + + this.locks.set(normalized, lockInfo) + + let taskSet = this.taskLocks.get(taskId) + if (!taskSet) { + taskSet = new Set() + this.taskLocks.set(taskId, taskSet) + } + taskSet.add(normalized) + + this.emit({ type: "lock-acquired", filePath: normalized, taskId }) + return true + } + + /** + * Release a lock held by a specific task. + * No-op if the task does not hold the lock. + */ + releaseLock(filePath: string, taskId: string): void { + const normalized = this.normalizePath(filePath) + const existing = this.locks.get(normalized) + + if (!existing || existing.taskId !== taskId) { + return + } + + this.locks.delete(normalized) + + const taskSet = this.taskLocks.get(taskId) + if (taskSet) { + taskSet.delete(normalized) + if (taskSet.size === 0) { + this.taskLocks.delete(taskId) + } + } + + this.emit({ type: "lock-released", filePath: normalized, taskId }) + } + + /** + * Release all locks held by a specific task. + * Called when a task completes, is cancelled, or errors out. + */ + releaseAllLocks(taskId: string): void { + const taskSet = this.taskLocks.get(taskId) + if (!taskSet || taskSet.size === 0) { + this.taskLocks.delete(taskId) + return + } + + const count = taskSet.size + + for (const normalized of taskSet) { + this.locks.delete(normalized) + } + + this.taskLocks.delete(taskId) + + this.emit({ type: "all-locks-released", taskId, count }) + } + + /** + * Check which task (if any) holds the lock on a file. + * Checks for expiration -- if the lock is expired, it is released and + * `undefined` is returned. + * + * @returns The taskId of the lock holder, or `undefined` if unlocked. + */ + getLockHolder(filePath: string): string | undefined { + const normalized = this.normalizePath(filePath) + const existing = this.locks.get(normalized) + + if (!existing) { + return undefined + } + + if (this.isLockExpired(existing)) { + this.forceReleaseLock(normalized, existing.taskId) + return undefined + } + + return existing.taskId + } + + /** + * Get detailed lock info for a file, or undefined if not locked. + * Checks for expiration. + */ + getLockInfo(filePath: string): FileLockInfo | undefined { + const normalized = this.normalizePath(filePath) + const existing = this.locks.get(normalized) + + if (!existing) { + return undefined + } + + if (this.isLockExpired(existing)) { + this.forceReleaseLock(normalized, existing.taskId) + return undefined + } + + return { ...existing } + } + + /** + * Get the conflict details when a lock acquisition would fail. + * Returns undefined if the file is not locked by another task. + */ + getLockConflict(filePath: string, taskId: string): LockConflict | undefined { + const normalized = this.normalizePath(filePath) + const existing = this.locks.get(normalized) + + if (!existing || existing.taskId === taskId) { + return undefined + } + + if (this.isLockExpired(existing)) { + this.forceReleaseLock(normalized, existing.taskId) + return undefined + } + + return { + filePath: normalized, + holdingTaskId: existing.taskId, + heldForMs: Date.now() - existing.acquiredAt, + } + } + + /** + * List all files currently locked by a specific task. + */ + getLockedFiles(taskId: string): string[] { + const taskSet = this.taskLocks.get(taskId) + if (!taskSet) { + return [] + } + return Array.from(taskSet) + } + + /** + * Get all currently held locks. Primarily for debugging/UI display. + * Expired locks are cleaned up during this call. + */ + getAllLocks(): FileLockInfo[] { + const result: FileLockInfo[] = [] + const expired: Array<{ normalized: string; taskId: string }> = [] + + for (const [normalized, info] of this.locks) { + if (this.isLockExpired(info)) { + expired.push({ normalized, taskId: info.taskId }) + } else { + result.push({ ...info }) + } + } + + // Clean up expired locks + for (const { normalized, taskId } of expired) { + this.forceReleaseLock(normalized, taskId) + } + + return result + } + + /** + * Get the total number of active locks. + */ + get lockCount(): number { + return this.locks.size + } + + /** + * Register an event listener. + */ + onEvent(listener: FileLockEventListener): void { + this.listeners.push(listener) + } + + /** + * Remove an event listener. + */ + offEvent(listener: FileLockEventListener): void { + const idx = this.listeners.indexOf(listener) + if (idx !== -1) { + this.listeners.splice(idx, 1) + } + } + + /** + * Clear all locks and listeners. Primarily for testing. + */ + dispose(): void { + this.locks.clear() + this.taskLocks.clear() + this.listeners = [] + } + + // --- Private helpers --- + + private normalizePath(filePath: string): string { + return path.resolve(filePath) + } + + private isLockExpired(info: FileLockInfo): boolean { + return Date.now() - info.acquiredAt > this.lockTimeoutMs + } + + private forceReleaseLock(normalized: string, taskId: string): void { + this.locks.delete(normalized) + + const taskSet = this.taskLocks.get(taskId) + if (taskSet) { + taskSet.delete(normalized) + if (taskSet.size === 0) { + this.taskLocks.delete(taskId) + } + } + + this.emit({ type: "lock-expired", filePath: normalized, taskId }) + } + + private emit(event: FileLockEvent): void { + for (const listener of this.listeners) { + try { + listener(event) + } catch { + // Swallow listener errors to avoid breaking lock operations + } + } + } +} diff --git a/src/services/file-lock/__tests__/FileLockManager.spec.ts b/src/services/file-lock/__tests__/FileLockManager.spec.ts new file mode 100644 index 0000000000..d00d66e5d7 --- /dev/null +++ b/src/services/file-lock/__tests__/FileLockManager.spec.ts @@ -0,0 +1,389 @@ +import path from "path" +import { FileLockManager, type FileLockEvent } from "../FileLockManager" + +describe("FileLockManager", () => { + let manager: FileLockManager + + beforeEach(() => { + manager = new FileLockManager() + }) + + afterEach(() => { + manager.dispose() + }) + + describe("acquireLock", () => { + it("should acquire a lock on an unlocked file", () => { + const result = manager.acquireLock("/project/foo.ts", "task-1") + expect(result).toBe(true) + expect(manager.getLockHolder("/project/foo.ts")).toBe("task-1") + }) + + it("should allow the same task to re-acquire a lock (re-entrant)", () => { + manager.acquireLock("/project/foo.ts", "task-1") + const result = manager.acquireLock("/project/foo.ts", "task-1") + expect(result).toBe(true) + expect(manager.getLockHolder("/project/foo.ts")).toBe("task-1") + }) + + it("should deny a lock when another task holds it", () => { + manager.acquireLock("/project/foo.ts", "task-1") + const result = manager.acquireLock("/project/foo.ts", "task-2") + expect(result).toBe(false) + expect(manager.getLockHolder("/project/foo.ts")).toBe("task-1") + }) + + it("should normalize paths so equivalent paths resolve to the same lock", () => { + manager.acquireLock("/project/src/../foo.ts", "task-1") + const result = manager.acquireLock("/project/foo.ts", "task-2") + expect(result).toBe(false) + }) + + it("should allow locking multiple different files by the same task", () => { + expect(manager.acquireLock("/project/a.ts", "task-1")).toBe(true) + expect(manager.acquireLock("/project/b.ts", "task-1")).toBe(true) + expect(manager.getLockedFiles("task-1")).toHaveLength(2) + }) + + it("should allow different tasks to lock different files", () => { + expect(manager.acquireLock("/project/a.ts", "task-1")).toBe(true) + expect(manager.acquireLock("/project/b.ts", "task-2")).toBe(true) + expect(manager.getLockHolder("/project/a.ts")).toBe("task-1") + expect(manager.getLockHolder("/project/b.ts")).toBe("task-2") + }) + }) + + describe("releaseLock", () => { + it("should release a lock held by the specified task", () => { + manager.acquireLock("/project/foo.ts", "task-1") + manager.releaseLock("/project/foo.ts", "task-1") + expect(manager.getLockHolder("/project/foo.ts")).toBeUndefined() + }) + + it("should be a no-op when the task does not hold the lock", () => { + manager.acquireLock("/project/foo.ts", "task-1") + manager.releaseLock("/project/foo.ts", "task-2") + expect(manager.getLockHolder("/project/foo.ts")).toBe("task-1") + }) + + it("should be a no-op when the file is not locked", () => { + // Should not throw + manager.releaseLock("/project/foo.ts", "task-1") + }) + + it("should allow another task to acquire the lock after release", () => { + manager.acquireLock("/project/foo.ts", "task-1") + manager.releaseLock("/project/foo.ts", "task-1") + expect(manager.acquireLock("/project/foo.ts", "task-2")).toBe(true) + }) + + it("should clean up taskLocks when the last lock for a task is released", () => { + manager.acquireLock("/project/foo.ts", "task-1") + manager.releaseLock("/project/foo.ts", "task-1") + expect(manager.getLockedFiles("task-1")).toHaveLength(0) + }) + }) + + describe("releaseAllLocks", () => { + it("should release all locks for a task", () => { + manager.acquireLock("/project/a.ts", "task-1") + manager.acquireLock("/project/b.ts", "task-1") + manager.acquireLock("/project/c.ts", "task-1") + manager.releaseAllLocks("task-1") + + expect(manager.getLockedFiles("task-1")).toHaveLength(0) + expect(manager.getLockHolder("/project/a.ts")).toBeUndefined() + expect(manager.getLockHolder("/project/b.ts")).toBeUndefined() + expect(manager.getLockHolder("/project/c.ts")).toBeUndefined() + }) + + it("should not affect locks held by other tasks", () => { + manager.acquireLock("/project/a.ts", "task-1") + manager.acquireLock("/project/b.ts", "task-2") + manager.releaseAllLocks("task-1") + + expect(manager.getLockHolder("/project/b.ts")).toBe("task-2") + }) + + it("should be a no-op when the task has no locks", () => { + // Should not throw + manager.releaseAllLocks("task-nonexistent") + }) + }) + + describe("getLockHolder", () => { + it("should return undefined for unlocked files", () => { + expect(manager.getLockHolder("/project/foo.ts")).toBeUndefined() + }) + + it("should return the task ID for locked files", () => { + manager.acquireLock("/project/foo.ts", "task-1") + expect(manager.getLockHolder("/project/foo.ts")).toBe("task-1") + }) + }) + + describe("getLockInfo", () => { + it("should return undefined for unlocked files", () => { + expect(manager.getLockInfo("/project/foo.ts")).toBeUndefined() + }) + + it("should return a copy of the lock info", () => { + manager.acquireLock("/project/foo.ts", "task-1") + const info = manager.getLockInfo("/project/foo.ts") + expect(info).toBeDefined() + expect(info!.taskId).toBe("task-1") + expect(info!.filePath).toBe(path.resolve("/project/foo.ts")) + expect(info!.acquiredAt).toBeGreaterThan(0) + }) + }) + + describe("getLockConflict", () => { + it("should return undefined when file is not locked", () => { + expect(manager.getLockConflict("/project/foo.ts", "task-1")).toBeUndefined() + }) + + it("should return undefined when the requesting task holds the lock", () => { + manager.acquireLock("/project/foo.ts", "task-1") + expect(manager.getLockConflict("/project/foo.ts", "task-1")).toBeUndefined() + }) + + it("should return conflict details when another task holds the lock", () => { + manager.acquireLock("/project/foo.ts", "task-1") + const conflict = manager.getLockConflict("/project/foo.ts", "task-2") + expect(conflict).toBeDefined() + expect(conflict!.holdingTaskId).toBe("task-1") + expect(conflict!.filePath).toBe(path.resolve("/project/foo.ts")) + expect(conflict!.heldForMs).toBeGreaterThanOrEqual(0) + }) + }) + + describe("getLockedFiles", () => { + it("should return empty array for a task with no locks", () => { + expect(manager.getLockedFiles("task-1")).toEqual([]) + }) + + it("should return all files locked by a task", () => { + manager.acquireLock("/project/a.ts", "task-1") + manager.acquireLock("/project/b.ts", "task-1") + const files = manager.getLockedFiles("task-1") + expect(files).toHaveLength(2) + expect(files).toContain(path.resolve("/project/a.ts")) + expect(files).toContain(path.resolve("/project/b.ts")) + }) + }) + + describe("getAllLocks", () => { + it("should return empty array when no locks exist", () => { + expect(manager.getAllLocks()).toEqual([]) + }) + + it("should return all active locks", () => { + manager.acquireLock("/project/a.ts", "task-1") + manager.acquireLock("/project/b.ts", "task-2") + const allLocks = manager.getAllLocks() + expect(allLocks).toHaveLength(2) + }) + }) + + describe("lockCount", () => { + it("should return 0 initially", () => { + expect(manager.lockCount).toBe(0) + }) + + it("should track the number of active locks", () => { + manager.acquireLock("/project/a.ts", "task-1") + expect(manager.lockCount).toBe(1) + manager.acquireLock("/project/b.ts", "task-1") + expect(manager.lockCount).toBe(2) + manager.releaseLock("/project/a.ts", "task-1") + expect(manager.lockCount).toBe(1) + }) + }) + + describe("lock expiration", () => { + it("should auto-expire locks after timeout and allow reacquisition", () => { + const shortTimeoutManager = new FileLockManager({ lockTimeoutMs: 50 }) + + shortTimeoutManager.acquireLock("/project/foo.ts", "task-1") + expect(shortTimeoutManager.getLockHolder("/project/foo.ts")).toBe("task-1") + + // Simulate time passing by directly manipulating the lock's acquiredAt + const locks = (shortTimeoutManager as any).locks as Map + const normalized = path.resolve("/project/foo.ts") + const lockInfo = locks.get(normalized) + lockInfo.acquiredAt = Date.now() - 100 // 100ms ago, > 50ms timeout + + // The lock should now be considered expired + expect(shortTimeoutManager.getLockHolder("/project/foo.ts")).toBeUndefined() + expect(shortTimeoutManager.acquireLock("/project/foo.ts", "task-2")).toBe(true) + + shortTimeoutManager.dispose() + }) + + it("should auto-expire locks during acquireLock by another task", () => { + const shortTimeoutManager = new FileLockManager({ lockTimeoutMs: 50 }) + + shortTimeoutManager.acquireLock("/project/foo.ts", "task-1") + + // Simulate expiry + const locks = (shortTimeoutManager as any).locks as Map + const normalized = path.resolve("/project/foo.ts") + locks.get(normalized).acquiredAt = Date.now() - 100 + + // Another task should be able to acquire the expired lock + expect(shortTimeoutManager.acquireLock("/project/foo.ts", "task-2")).toBe(true) + expect(shortTimeoutManager.getLockHolder("/project/foo.ts")).toBe("task-2") + + shortTimeoutManager.dispose() + }) + + it("should clean up expired locks during getAllLocks", () => { + const shortTimeoutManager = new FileLockManager({ lockTimeoutMs: 50 }) + + shortTimeoutManager.acquireLock("/project/a.ts", "task-1") + shortTimeoutManager.acquireLock("/project/b.ts", "task-2") + + // Expire only task-1's lock + const locks = (shortTimeoutManager as any).locks as Map + locks.get(path.resolve("/project/a.ts")).acquiredAt = Date.now() - 100 + + const allLocks = shortTimeoutManager.getAllLocks() + expect(allLocks).toHaveLength(1) + expect(allLocks[0].taskId).toBe("task-2") + + shortTimeoutManager.dispose() + }) + + it("should return undefined from getLockConflict for expired locks", () => { + const shortTimeoutManager = new FileLockManager({ lockTimeoutMs: 50 }) + + shortTimeoutManager.acquireLock("/project/foo.ts", "task-1") + + // Expire the lock + const locks = (shortTimeoutManager as any).locks as Map + locks.get(path.resolve("/project/foo.ts")).acquiredAt = Date.now() - 100 + + expect(shortTimeoutManager.getLockConflict("/project/foo.ts", "task-2")).toBeUndefined() + + shortTimeoutManager.dispose() + }) + }) + + describe("events", () => { + it("should emit lock-acquired events", () => { + const events: FileLockEvent[] = [] + manager.onEvent((e) => events.push(e)) + + manager.acquireLock("/project/foo.ts", "task-1") + + expect(events).toHaveLength(1) + expect(events[0].type).toBe("lock-acquired") + expect(events[0].taskId).toBe("task-1") + }) + + it("should emit lock-released events", () => { + const events: FileLockEvent[] = [] + manager.acquireLock("/project/foo.ts", "task-1") + + manager.onEvent((e) => events.push(e)) + manager.releaseLock("/project/foo.ts", "task-1") + + expect(events).toHaveLength(1) + expect(events[0].type).toBe("lock-released") + }) + + it("should emit all-locks-released events", () => { + const events: FileLockEvent[] = [] + manager.acquireLock("/project/a.ts", "task-1") + manager.acquireLock("/project/b.ts", "task-1") + + manager.onEvent((e) => events.push(e)) + manager.releaseAllLocks("task-1") + + expect(events).toHaveLength(1) + expect(events[0].type).toBe("all-locks-released") + if (events[0].type === "all-locks-released") { + expect(events[0].count).toBe(2) + } + }) + + it("should emit lock-expired events when a lock times out", () => { + const shortTimeoutManager = new FileLockManager({ lockTimeoutMs: 50 }) + const events: FileLockEvent[] = [] + shortTimeoutManager.onEvent((e) => events.push(e)) + + shortTimeoutManager.acquireLock("/project/foo.ts", "task-1") + + // Expire the lock + const locks = (shortTimeoutManager as any).locks as Map + locks.get(path.resolve("/project/foo.ts")).acquiredAt = Date.now() - 100 + + // Trigger expiration check via getLockHolder + shortTimeoutManager.getLockHolder("/project/foo.ts") + + const expiredEvents = events.filter((e) => e.type === "lock-expired") + expect(expiredEvents).toHaveLength(1) + + shortTimeoutManager.dispose() + }) + + it("should allow removing event listeners", () => { + const events: FileLockEvent[] = [] + const listener = (e: FileLockEvent) => events.push(e) + + manager.onEvent(listener) + manager.acquireLock("/project/foo.ts", "task-1") + expect(events).toHaveLength(1) + + manager.offEvent(listener) + manager.acquireLock("/project/bar.ts", "task-1") + expect(events).toHaveLength(1) // No new events + }) + + it("should not throw if a listener throws", () => { + manager.onEvent(() => { + throw new Error("listener error") + }) + + // Should not throw + expect(() => manager.acquireLock("/project/foo.ts", "task-1")).not.toThrow() + }) + }) + + describe("dispose", () => { + it("should clear all locks and listeners", () => { + const events: FileLockEvent[] = [] + manager.onEvent((e) => events.push(e)) + + manager.acquireLock("/project/a.ts", "task-1") + manager.acquireLock("/project/b.ts", "task-2") + manager.dispose() + + expect(manager.lockCount).toBe(0) + expect(manager.getAllLocks()).toEqual([]) + + // Listener should have been removed + manager.acquireLock("/project/c.ts", "task-3") + expect(events).toHaveLength(2) // Only the pre-dispose events + }) + }) + + describe("re-entrant lock refresh", () => { + it("should refresh the acquiredAt timestamp on re-entrant lock", () => { + manager.acquireLock("/project/foo.ts", "task-1") + const info1 = manager.getLockInfo("/project/foo.ts") + + // Small delay to ensure timestamp differs + const originalTime = info1!.acquiredAt + + // Manipulate time to verify refresh + const locks = (manager as any).locks as Map + const normalized = path.resolve("/project/foo.ts") + locks.get(normalized).acquiredAt = originalTime - 1000 + + manager.acquireLock("/project/foo.ts", "task-1") + const info2 = manager.getLockInfo("/project/foo.ts") + expect(info2!.acquiredAt).toBeGreaterThan(originalTime - 1000) + }) + }) +}) diff --git a/src/services/file-lock/index.ts b/src/services/file-lock/index.ts new file mode 100644 index 0000000000..bd61c47d85 --- /dev/null +++ b/src/services/file-lock/index.ts @@ -0,0 +1,8 @@ +export { + FileLockManager, + type FileLockManagerOptions, + type FileLockInfo, + type LockConflict, + type FileLockEvent, + type FileLockEventListener, +} from "./FileLockManager" diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 0179e1c551..4ecba976bb 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -105,9 +105,14 @@ export type NativeToolArgs = { edit_file: { file_path: string; old_string: string; new_string: string; expected_replacements?: number } apply_patch: { patch: string } list_files: { path: string; recursive?: boolean } - new_task: { mode: string; message: string; todos?: string; task_queue?: string; permissions?: string } - new_task: { mode: string; message: string; todos?: string; background?: string } - new_task: { mode: string; message: string; todos?: string } + new_task: { + mode: string + message: string + todos?: string + task_queue?: string + permissions?: string + background?: string + } ask_followup_question: { question: string follow_up: Array<{ text: string; mode?: string }> @@ -245,10 +250,9 @@ export interface SwitchModeToolUse extends ToolUse<"switch_mode"> { export interface NewTaskToolUse extends ToolUse<"new_task"> { name: "new_task" - params: Partial, "mode" | "message" | "todos" | "task_queue">> - params: Partial, "mode" | "message" | "todos" | "permissions">> - params: Partial, "mode" | "message" | "todos" | "background">> - params: Partial, "mode" | "message" | "todos">> + params: Partial< + Pick, "mode" | "message" | "todos" | "task_queue" | "permissions" | "background"> + > } export interface RunSlashCommandToolUse extends ToolUse<"run_slash_command"> {