Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions server/claude-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,16 @@ describe('handleTaskTool', () => {
expect(cp.tasks.size).toBe(1)
})

it('clears existing tasks and resets ID sequence', () => {
it('clears existing tasks but keeps the ID sequence monotonic', () => {
cp.handleTaskTool('TaskCreate', { subject: 'Old task' })
expect(cp.tasks.size).toBe(1)
cp.handleTaskTool('TodoWrite', {
todos: [{ content: 'New', status: 'in_progress' }],
})
expect(cp.tasks.size).toBe(1)
expect(cp.tasks.get('1')!.subject).toBe('New')
// New list gets a fresh id (not reused '1') so the frontend can detect
// a replaced list and later TaskCreate/TaskUpdate ids never collide.
expect(cp.tasks.get('2')!.subject).toBe('New')
})

it('returns false for non-array todos', () => {
Expand Down Expand Up @@ -182,8 +184,18 @@ describe('handleTaskTool', () => {
expect(cp.tasks.has('1')).toBe(false)
})

it('returns false for nonexistent task', () => {
expect(cp.handleTaskTool('TaskUpdate', { taskId: '999', status: 'completed' })).toBe(false)
it('upserts a task for an unknown id instead of dropping the update', () => {
expect(cp.handleTaskTool('TaskUpdate', { taskId: '999', status: 'completed' })).toBe(true)
expect(cp.tasks.get('999')!.status).toBe('completed')
expect(cp.tasks.get('999')!.subject).toBe('Task 999')
})

it('returns false for unknown id with deleted status', () => {
expect(cp.handleTaskTool('TaskUpdate', { taskId: '999', status: 'deleted' })).toBe(false)
})

it('returns false for missing taskId', () => {
expect(cp.handleTaskTool('TaskUpdate', { status: 'completed' })).toBe(false)
})

it('updates subject and activeForm', () => {
Expand All @@ -196,6 +208,23 @@ describe('handleTaskTool', () => {
it('returns false for unknown tool', () => {
expect(cp.handleTaskTool('UnknownTool', {})).toBe(false)
})

describe('seedTasks', () => {
it('restores tasks and continues the id sequence past seeded ids', () => {
cp.seedTasks([
{ id: '3', subject: 'Restored A', status: 'completed' },
{ id: '4', subject: 'Restored B', status: 'in_progress' },
])
expect(cp.tasks.size).toBe(2)
// TaskUpdate against a pre-restart id now resolves to the real task
cp.handleTaskTool('TaskUpdate', { taskId: '4', status: 'completed' })
expect(cp.tasks.get('4')!.status).toBe('completed')
expect(cp.tasks.get('4')!.subject).toBe('Restored B')
// New TaskCreate ids do not collide with seeded ids
cp.handleTaskTool('TaskCreate', { subject: 'Next' })
expect(cp.tasks.get('5')!.subject).toBe('Next')
})
})
})

describe('handleStreamEvent (via handleLine)', () => {
Expand Down
46 changes: 40 additions & 6 deletions server/claude-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,10 +268,10 @@
}
}, 60_000)

this.rl = createInterface({ input: this.proc.stdout! })

Check warning on line 271 in server/claude-process.ts

View workflow job for this annotation

GitHub Actions / test-and-lint (22)

Forbidden non-null assertion

Check warning on line 271 in server/claude-process.ts

View workflow job for this annotation

GitHub Actions / test-and-lint (20)

Forbidden non-null assertion
this.rl.on('line', (line) => this.handleLine(line))

Check warning on line 272 in server/claude-process.ts

View workflow job for this annotation

GitHub Actions / test-and-lint (22)

Returning a void expression from an arrow function shorthand is forbidden. Please add braces to the arrow function

Check warning on line 272 in server/claude-process.ts

View workflow job for this annotation

GitHub Actions / test-and-lint (20)

Returning a void expression from an arrow function shorthand is forbidden. Please add braces to the arrow function

this.proc.stderr!.on('data', (data: Buffer) => {

Check warning on line 274 in server/claude-process.ts

View workflow job for this annotation

GitHub Actions / test-and-lint (22)

Forbidden non-null assertion

Check warning on line 274 in server/claude-process.ts

View workflow job for this annotation

GitHub Actions / test-and-lint (20)

Forbidden non-null assertion
const text = data.toString().trim()
console.error('[claude stderr]', text)
if (text) {
Expand Down Expand Up @@ -388,7 +388,7 @@

case 'control_request': {
const ctrlEvent = event as ClaudeControlRequest
if (TOOL_DEBUG) console.log(`[control_request] requestId=${ctrlEvent.request_id} tool=${ctrlEvent.request?.tool_name}`)

Check warning on line 391 in server/claude-process.ts

View workflow job for this annotation

GitHub Actions / test-and-lint (22)

Unnecessary optional chain on a non-nullish value

Check warning on line 391 in server/claude-process.ts

View workflow job for this annotation

GitHub Actions / test-and-lint (20)

Unnecessary optional chain on a non-nullish value
this.handleControlRequest(ctrlEvent)
break
}
Expand Down Expand Up @@ -420,7 +420,7 @@
*/
private handleStreamEvent(event: ClaudeStreamEvent): void {
const inner = event.event
if (!inner) return

Check warning on line 423 in server/claude-process.ts

View workflow job for this annotation

GitHub Actions / test-and-lint (22)

Unnecessary conditional, value is always falsy

Check warning on line 423 in server/claude-process.ts

View workflow job for this annotation

GitHub Actions / test-and-lint (20)

Unnecessary conditional, value is always falsy

switch (inner.type) {
case 'content_block_start':
Expand All @@ -433,7 +433,7 @@
} else if (this.tool.name === 'ExitPlanMode') {
this.emit('planning_mode', false)
} else {
this.emit('tool_active', this.tool.name!, undefined)

Check warning on line 436 in server/claude-process.ts

View workflow job for this annotation

GitHub Actions / test-and-lint (22)

Forbidden non-null assertion

Check warning on line 436 in server/claude-process.ts

View workflow job for this annotation

GitHub Actions / test-and-lint (20)

Forbidden non-null assertion
}
} else if (inner.content_block?.type === 'thinking') {
this.thinking = { active: true, text: '', summaryEmitted: false }
Expand Down Expand Up @@ -481,12 +481,14 @@
let summary: string | undefined
try {
const parsed = jsonParse(this.tool.input) as Record<string, unknown>
summary = summarizeToolInput(this.tool.name!, parsed) || undefined

Check warning on line 484 in server/claude-process.ts

View workflow job for this annotation

GitHub Actions / test-and-lint (22)

Forbidden non-null assertion

Check warning on line 484 in server/claude-process.ts

View workflow job for this annotation

GitHub Actions / test-and-lint (20)

Forbidden non-null assertion
const isTask = this.tool.name === 'TaskCreate' || this.tool.name === 'TaskUpdate' || this.tool.name === 'TodoWrite' || this.tool.name === 'TodoRead'
if (isTask && TOOL_DEBUG) console.log('[task-debug] tool:', this.tool.name, 'input:', JSON.stringify(parsed).slice(0, 200))
if (this.handleTaskTool(this.tool.name!, parsed)) {
if (TOOL_DEBUG) console.log('[task-debug] emitting todo_update, tasks:', this.tasks.size)
this.emit('todo_update', Array.from(this.tasks.values()))
// Copy items so later in-place TaskUpdate mutations don't alias into
// previously broadcast/history-stored snapshots.
this.emit('todo_update', Array.from(this.tasks.values(), t => ({ ...t })))
}
} catch (err) {
console.warn(`[claude] Failed to parse tool input for ${this.tool.name}:`, err instanceof Error ? err.message : err)
Expand Down Expand Up @@ -648,14 +650,17 @@
* Returns true if the task list changed (caller should emit todo_update).
*/
private handleTaskTool(toolName: string, input: Record<string, unknown>): boolean {
// TodoWrite sends the entire list at once
// TodoWrite sends the entire list at once.
// Note: taskSeq is intentionally NOT reset — keeping ids monotonic across
// list generations lets the frontend detect a brand-new list by id and
// avoids id collisions with later TaskCreate/TaskUpdate calls.
if (toolName === 'TodoWrite') {
const todos = input.todos as Array<Record<string, unknown>> | undefined
if (!Array.isArray(todos)) return false
this.tasks.clear()
this.taskSeq = 0
for (const item of todos) {
const id = String(item.id || ++this.taskSeq)
this.syncTaskSeq(id)
const status = item.status as string
if (status !== 'pending' && status !== 'in_progress' && status !== 'completed') continue
this.tasks.set(id, {
Expand All @@ -669,7 +674,8 @@
}
// TaskCreate/TaskUpdate are the newer tool names
if (toolName === 'TaskCreate') {
const id = String(++this.taskSeq)
const id = String(input.taskId || input.id || ++this.taskSeq)
this.syncTaskSeq(id)
this.tasks.set(id, {
id,
subject: String(input.subject || ''),
Expand All @@ -680,8 +686,17 @@
}
if (toolName === 'TaskUpdate') {
const id = String(input.taskId || '')
const task = this.tasks.get(id)
if (!task) return false
if (!id) return false
let task = this.tasks.get(id)
if (!task) {
// Unknown id — our in-memory map can diverge from the CLI's real task
// list (e.g. after a process restart mid-session). Upsert instead of
// dropping the update, otherwise the UI shows a stale list forever.
if (input.status === 'deleted') return false
task = { id, subject: String(input.subject || `Task ${id}`), status: 'pending' }
this.tasks.set(id, task)
this.syncTaskSeq(id)
}
const status = input.status as string | undefined
if (status === 'deleted') {
this.tasks.delete(id)
Expand All @@ -697,6 +712,25 @@
return false
}

/** Keep taskSeq ahead of any numeric id we have seen, so generated ids never collide. */
private syncTaskSeq(id: string): void {
const n = Number(id)
if (Number.isInteger(n) && n > this.taskSeq) this.taskSeq = n
}

/**
* Seed task state from a previous process's last known list (session restore).
* Without this, a restarted process starts with an empty map and TaskUpdate
* calls referencing pre-restart task ids would otherwise be lost.
*/
seedTasks(tasks: TaskItem[]): void {
this.tasks.clear()
for (const t of tasks) {
this.tasks.set(t.id, { ...t })
this.syncTaskSeq(t.id)
}
}

/**
* Extract a short summary from extended thinking text.
* Tries to grab the first sentence (up to 120 chars), or truncates at a
Expand Down
6 changes: 6 additions & 0 deletions server/codex-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,12 @@ export class CodexProcess extends EventEmitter<ClaudeProcessEvents> implements C
}
}

/**
* No-op: Codex emits the full plan on every turn/plan/updated notification,
* so there is no incremental task state to seed after a restart.
*/
seedTasks(): void {}

isAlive(): boolean {
return this.alive
}
Expand Down
4 changes: 4 additions & 0 deletions server/coding-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import type { EventEmitter } from 'events'
import type { ClaudeProcessEvents } from './claude-process.js'
import type { TaskItem } from './types.js'

/**
* Supported AI coding assistant providers.
Expand Down Expand Up @@ -81,6 +82,9 @@ export interface CodingProcess extends EventEmitter<ClaudeProcessEvents> {
*/
setPermissionMode?(mode: import('./types.js').PermissionMode): Promise<boolean>

/** Seed task/todo state from a previous process's last known list (session restore). */
seedTasks(tasks: TaskItem[]): void

/** Whether the process is currently running and accepting input. */
isAlive(): boolean

Expand Down
44 changes: 38 additions & 6 deletions server/opencode-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -889,13 +889,13 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
this.emit('tool_active', toolName, inputStr)
// Detect task/todo tool calls and emit todo_update
if (part.state?.input && this.handleTaskTool(toolName, part.state.input)) {
this.emit('todo_update', Array.from(this.tasks.values()))
this.emit('todo_update', Array.from(this.tasks.values(), t => ({ ...t })))
}
} else if (status === 'completed') {
// Also check for task tools at completion (some providers only
// populate input at this stage, not during 'running')
if (part.state?.input && this.handleTaskTool(toolName, part.state.input)) {
this.emit('todo_update', Array.from(this.tasks.values()))
this.emit('todo_update', Array.from(this.tasks.values(), t => ({ ...t })))
}
const output = part.state?.output
const summary = output ? output.slice(0, 200) : undefined
Expand Down Expand Up @@ -1265,13 +1265,16 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
private handleTaskTool(toolName: string, input: Record<string, unknown>): boolean {
// Normalize tool name — OpenCode may report as 'todowrite', 'TodoWrite', 'todo_write', etc.
const normalized = toolName.toLowerCase().replace(/_/g, '')
// Note: taskSeq is intentionally NOT reset on TodoWrite — keeping ids
// monotonic across list generations lets the frontend detect a brand-new
// list by id and avoids collisions with later TaskCreate/TaskUpdate calls.
if (normalized === 'todowrite') {
const todos = input.todos as Array<Record<string, unknown>> | undefined
if (!Array.isArray(todos)) return false
this.tasks.clear()
this.taskSeq = 0
for (const item of todos) {
const id = String(item.id || ++this.taskSeq)
this.syncTaskSeq(id)
const status = item.status as string
if (status !== 'pending' && status !== 'in_progress' && status !== 'completed') continue
this.tasks.set(id, {
Expand All @@ -1284,7 +1287,8 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
return true
}
if (normalized === 'taskcreate') {
const id = String(++this.taskSeq)
const id = String(input.taskId || input.id || ++this.taskSeq)
this.syncTaskSeq(id)
this.tasks.set(id, {
id,
subject: String(input.subject || ''),
Expand All @@ -1295,8 +1299,17 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
}
if (normalized === 'taskupdate') {
const id = String(input.taskId || '')
const task = this.tasks.get(id)
if (!task) return false
if (!id) return false
let task = this.tasks.get(id)
if (!task) {
// Unknown id — our in-memory map can diverge from the provider's real
// task list (e.g. after a process restart mid-session). Upsert instead
// of dropping the update, otherwise the UI shows a stale list forever.
if (input.status === 'deleted') return false
task = { id, subject: String(input.subject || `Task ${id}`), status: 'pending' }
this.tasks.set(id, task)
this.syncTaskSeq(id)
}
const status = input.status as string | undefined
if (status === 'deleted') {
this.tasks.delete(id)
Expand All @@ -1312,6 +1325,25 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
return false
}

/** Keep taskSeq ahead of any numeric id we have seen, so generated ids never collide. */
private syncTaskSeq(id: string): void {
const n = Number(id)
if (Number.isInteger(n) && n > this.taskSeq) this.taskSeq = n
}

/**
* Seed task state from a previous process's last known list (session restore).
* Without this, a restarted process starts with an empty map and TaskUpdate
* calls referencing pre-restart task ids would otherwise be lost.
*/
seedTasks(tasks: TaskItem[]): void {
this.tasks.clear()
for (const t of tasks) {
this.tasks.set(t.id, { ...t })
this.syncTaskSeq(t.id)
}
}

/** Send a user message to the OpenCode session. */
sendMessage(content: string): void {
if (!this.alive || !this.opencodeSessionId) {
Expand Down
12 changes: 12 additions & 0 deletions server/session-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,18 @@ export class SessionLifecycle {

this.wireClaudeEvents(cp, session, sessionId)

// On resume, carry the last known task list into the new process so
// TaskUpdate calls referencing pre-restart task ids aren't dropped.
if (resume) {
for (let i = session.outputHistory.length - 1; i >= 0; i--) {
const msg = session.outputHistory[i]
if (msg.type === 'todo_update') {
cp.seedTasks(msg.tasks)
break
}
}
}

cp.start()
session.claudeProcess = cp

Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,7 @@ export default function App() {
planningMode={planningMode}
activityLabel={activityLabel}
tasks={tasks}
isProcessing={isProcessing}
activePrompt={activePrompt}
sendPromptResponse={sendPromptResponse}
inputBarRef={inputBarRef}
Expand Down Expand Up @@ -733,6 +734,7 @@ export default function App() {
planningMode={planningMode}
activityLabel={activityLabel}
tasks={tasks}
isProcessing={isProcessing}
disabled={!settings.token}
hasFileChanges={hasFileChanges}
diffPanelOpen={diffPanelOpen}
Expand Down
4 changes: 3 additions & 1 deletion src/components/OrchestratorContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface OrchestratorContentProps {
planningMode: boolean
activityLabel?: string
tasks: TaskItem[]
isProcessing: boolean
activePrompt: PromptEntry | null
sendPromptResponse: (value: string | string[], requestId?: string) => void
inputBarRef: RefObject<InputBarHandle | null>
Expand Down Expand Up @@ -55,6 +56,7 @@ export function OrchestratorContent({
planningMode,
activityLabel,
tasks,
isProcessing,
activePrompt,
sendPromptResponse,
inputBarRef,
Expand Down Expand Up @@ -93,7 +95,7 @@ export function OrchestratorContent({
variant="orchestrator"
agentName={agentName}
/>
<TodoPanel tasks={tasks} />
<TodoPanel tasks={tasks} isProcessing={isProcessing} />
</div>
{activePrompt && (
<PromptButtons
Expand Down
4 changes: 3 additions & 1 deletion src/components/SessionContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface SessionContentProps {
planningMode: boolean
activityLabel?: string
tasks: TaskItem[]
isProcessing: boolean
disabled: boolean
hasFileChanges: boolean
diffPanelOpen: boolean
Expand Down Expand Up @@ -76,6 +77,7 @@ export function SessionContent({
planningMode,
activityLabel,
tasks,
isProcessing,
disabled,
hasFileChanges,
diffPanelOpen,
Expand Down Expand Up @@ -126,7 +128,7 @@ export function SessionContent({
activityLabel={isProviderDisabled ? undefined : activityLabel}
isMobile={isMobile}
/>
<TodoPanel tasks={tasks} />
<TodoPanel tasks={tasks} isProcessing={isProcessing} />

{/* Claude Code disabled banner */}
{claudeDisabled && (
Expand Down
Loading
Loading