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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ All notable changes to DreamCoder will be documented in this file.

- Confirmation dialogs keep long workspace paths inside their bounds, so Cancel and Enable remain visible when enabling bypass permissions (#226). Thanks @imshah-id.
- Images attached from uploads, paste or the built-in browser are kept unchanged within the client limits and resized or compressed when they exceed 2000×2000 pixels or 3.75 MB; a re-encoded image is named after its new type. An image the app cannot decode is attached as it is when it is within 3.75 MB. A file that cannot be attached is named in a message, and the other files picked or dropped with it are still attached. Sent images now use compact thumbnails that still open in the gallery (#218). Thanks @henricop.
- A lead session that is resumed after a restart keeps using its team's task list (#216). The lead looked for tasks in a list named after its session, so `TaskList` returned nothing and new tasks went to a different list than the one the teammates read. The lead now goes back to the team it created when the session is resumed. Thanks @WhitePepperLambSoup.

## [0.8.0] — 2026-11-01

Expand Down
5 changes: 5 additions & 0 deletions src/cli/print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ import {
markMessagesAsRead,
isShutdownApproved,
} from '../utils/teammateMailbox.js'
import { restoreLeaderTeamNameOnResume } from '../utils/swarm/reconnection.js'
import { removeTeammateFromTeamFile } from '../utils/swarm/teamHelpers.js'
import { unassignTeammateTasks } from '../utils/tasks.js'
import { getRunningTasks } from '../utils/task/framework.js'
Expand Down Expand Up @@ -735,6 +736,10 @@ export async function runHeadless(
return
}

if (options.continue || options.resume) {
restoreLeaderTeamNameOnResume()
}

// Handle --rewind-files: restore filesystem and exit immediately
if (options.rewindFiles) {
// File history snapshots are only created for user messages,
Expand Down
140 changes: 140 additions & 0 deletions src/utils/swarm/reconnection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { getSessionId } from '../../bootstrap/state.js'
import {
clearLeaderTeamName,
createTask,
getTaskListId,
listTasks,
type Task,
} from '../tasks.js'
import {
restoreLeaderTeamName,
restoreLeaderTeamNameOnResume,
} from './reconnection.js'

const configDirAtLoad = process.env.DREAMCODER_CONFIG_DIR
const userTypeAtLoad = process.env.USER_TYPE
const agentTeamsAtLoad = process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS
let tmpDir: string

function restoreEnv(name: string, value: string | undefined): void {
if (value === undefined) delete process.env[name]
else process.env[name] = value
}

const pendingTask = (subject: string): Omit<Task, 'id'> => ({
subject,
description: '',
status: 'pending',
blocks: [],
blockedBy: [],
})

async function writeTeam(
name: string,
leadSessionId: string,
createdAt = 1,
): Promise<void> {
const teamDir = path.join(tmpDir, 'teams', name)
await fs.mkdir(teamDir, { recursive: true })
await fs.writeFile(
path.join(teamDir, 'config.json'),
JSON.stringify({
name,
createdAt,
leadAgentId: `team-lead@${name}`,
leadSessionId,
members: [],
}),
)
}

beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'reconnection-'))
process.env.DREAMCODER_CONFIG_DIR = tmpDir
})

afterEach(async () => {
clearLeaderTeamName()
process.env.DREAMCODER_CONFIG_DIR = configDirAtLoad
await fs.rm(tmpDir, { recursive: true, force: true })
})

describe('restoreLeaderTeamName', () => {
it('points the lead back at the team it created before a restart', async () => {
await writeTeam('keep-test', getSessionId())
await createTask('keep-test', pendingTask('Write the intro'))
expect(getTaskListId()).toBe(getSessionId())

restoreLeaderTeamName()

expect(getTaskListId()).toBe('keep-test')
const tasks = await listTasks(getTaskListId())
expect(tasks.map(task => task.subject)).toEqual(['Write the intro'])
})

it('ignores teams led by another session', async () => {
await writeTeam('someone-elses', 'another-session')

restoreLeaderTeamName()

expect(getTaskListId()).toBe(getSessionId())
})

it('uses the newest team when the session led more than one', async () => {
await writeTeam('older', getSessionId(), 1)
await writeTeam('newer', getSessionId(), 2)

restoreLeaderTeamName()

expect(getTaskListId()).toBe('newer')
})

it('does nothing when no team has been created', () => {
restoreLeaderTeamName()

expect(getTaskListId()).toBe(getSessionId())
})
})

describe('restoreLeaderTeamNameOnResume', () => {
beforeEach(() => {
delete process.env.USER_TYPE
delete process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS
})

afterEach(() => {
restoreEnv('USER_TYPE', userTypeAtLoad)
restoreEnv('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS', agentTeamsAtLoad)
})

it('restores the team name when agent teams are enabled', async () => {
process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1'
await writeTeam('keep-test', getSessionId())

restoreLeaderTeamNameOnResume()

expect(getTaskListId()).toBe('keep-test')
})

it('restores nothing when agent teams are disabled', async () => {
await writeTeam('keep-test', getSessionId())

restoreLeaderTeamNameOnResume()

expect(getTaskListId()).toBe(getSessionId())
})

it('does not take over the team of the session it was forked from', async () => {
process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1'
await writeTeam('original-team', 'original-session')
expect(getSessionId()).not.toBe('original-session')

restoreLeaderTeamNameOnResume()

expect(getTaskListId()).toBe(getSessionId())
})
})
37 changes: 37 additions & 0 deletions src/utils/swarm/reconnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@
* - Resumed sessions: Initialize from teamName/agentName stored in the transcript
*/

import { readdirSync } from 'fs'
import { getSessionId } from '../../bootstrap/state.js'
import type { AppState } from '../../state/AppState.js'
import { isAgentSwarmsEnabled } from '../agentSwarmsEnabled.js'
import { logForDebugging } from '../debug.js'
import { getTeamsDir } from '../envUtils.js'
import { errorMessage, getErrnoCode } from '../errors.js'
import { logError } from '../log.js'
import { setLeaderTeamName } from '../tasks.js'
import { getDynamicTeamContext } from '../teammate.js'
import { getTeamFilePath, readTeamFile } from './teamHelpers.js'

Expand Down Expand Up @@ -65,6 +71,37 @@ export function computeInitialTeamContext():
}
}

export function restoreLeaderTeamName(): void {
let teamNames: string[]
try {
teamNames = readdirSync(getTeamsDir())
} catch (e) {
if (getErrnoCode(e) !== 'ENOENT') {
logForDebugging(`[Reconnection] Failed to list teams: ${errorMessage(e)}`)
}
return
}

const sessionId = getSessionId()
let newest: { name: string; createdAt: number } | undefined
for (const name of teamNames) {
const teamFile = readTeamFile(name)
if (teamFile?.leadSessionId !== sessionId) continue
if (!newest || teamFile.createdAt > newest.createdAt) {
newest = { name, createdAt: teamFile.createdAt }
}
}

if (newest) {
setLeaderTeamName(newest.name)
}
}

export function restoreLeaderTeamNameOnResume(): void {
if (!isAgentSwarmsEnabled()) return
restoreLeaderTeamName()
}

/**
* Initialize teammate context from a resumed session.
*
Expand Down
Loading