merge: integrate origin/dev into modular-enforcement branch

Resolves all merge conflicts, preserving our split module structure
while integrating all dev changes:
- Custom agent summaries support (parseRegisteredAgentSummaries)
- Background notification queue (enqueueNotificationForParent)
- Atlas shared git-worktree module (collectGitDiffStats, formatFileChanges)
- Ralph-loop withTimeout + DEFAULT_API_TIMEOUT=5000
- Session recovery assistant_prefill_unsupported error type
- Atlas agentOverrides forwarding
- Config handler plan model demotion (buildPlanDemoteConfig)
- Delegate-task agentOverrides, promptSyncWithModelSuggestionRetry, variant
- LSP init timeout + stale init detection
- isPlanFamily function + task-continuation-enforcer hook
- Handoff command
This commit is contained in:
YeonGyu-Kim
2026-02-08 17:34:47 +09:00
74 changed files with 4016 additions and 654 deletions
@@ -1123,6 +1123,99 @@ describe("BackgroundManager.tryCompleteTask", () => {
expect(task.status).toBe("completed")
expect(getPendingByParent(manager).get(task.parentSessionID)).toBeUndefined()
})
test("should avoid overlapping promptAsync calls when tasks complete concurrently", async () => {
// given
type PromptAsyncBody = Record<string, unknown> & { noReply?: boolean }
let resolveMessages: ((value: { data: unknown[] }) => void) | undefined
const messagesBarrier = new Promise<{ data: unknown[] }>((resolve) => {
resolveMessages = resolve
})
const promptBodies: PromptAsyncBody[] = []
let promptInFlight = false
let rejectedCount = 0
let promptCallCount = 0
let releaseFirstPrompt: (() => void) | undefined
let resolveFirstStarted: (() => void) | undefined
const firstStarted = new Promise<void>((resolve) => {
resolveFirstStarted = resolve
})
const client = {
session: {
prompt: async () => ({}),
abort: async () => ({}),
messages: async () => messagesBarrier,
promptAsync: async (args: { path: { id: string }; body: PromptAsyncBody }) => {
promptBodies.push(args.body)
if (!promptInFlight) {
promptCallCount += 1
if (promptCallCount === 1) {
promptInFlight = true
resolveFirstStarted?.()
return await new Promise((resolve) => {
releaseFirstPrompt = () => {
promptInFlight = false
resolve({})
}
})
}
return {}
}
rejectedCount += 1
throw new Error("BUSY")
},
},
}
manager.shutdown()
manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const parentSessionID = "parent-session"
const taskA = createMockTask({
id: "task-a",
sessionID: "session-a",
parentSessionID,
})
const taskB = createMockTask({
id: "task-b",
sessionID: "session-b",
parentSessionID,
})
getTaskMap(manager).set(taskA.id, taskA)
getTaskMap(manager).set(taskB.id, taskB)
getPendingByParent(manager).set(parentSessionID, new Set([taskA.id, taskB.id]))
// when
const completionA = tryCompleteTaskForTest(manager, taskA)
const completionB = tryCompleteTaskForTest(manager, taskB)
resolveMessages?.({ data: [] })
await firstStarted
// Give the second completion a chance to attempt promptAsync while the first is in-flight.
// In the buggy implementation, this triggers an overlap and increments rejectedCount.
for (let i = 0; i < 20; i++) {
await Promise.resolve()
if (rejectedCount > 0) break
if (promptBodies.length >= 2) break
}
releaseFirstPrompt?.()
await Promise.all([completionA, completionB])
// then
expect(rejectedCount).toBe(0)
expect(promptBodies.length).toBe(2)
expect(promptBodies.some((b) => b.noReply === false)).toBe(true)
})
})
describe("BackgroundManager.trackTask", () => {
+28 -5
View File
@@ -41,6 +41,7 @@ export class BackgroundManager {
private processingKeys = new Set<string>()
private completionTimers = new Map<string, ReturnType<typeof setTimeout>>()
private idleDeferralTimers = new Map<string, ReturnType<typeof setTimeout>>()
private notificationQueueByParent = new Map<string, Promise<void>>()
private client: PluginInput["client"]
private directory: string
@@ -72,7 +73,7 @@ export class BackgroundManager {
}
async resume(input: ResumeInput): Promise<BackgroundTask> {
return resumeBackgroundTask({ input, findBySession: (id) => this.findBySession(id), client: this.client, concurrencyManager: this.concurrencyManager, pendingByParent: this.pendingByParent, startPolling: () => this.startPolling(), markForNotification: (task) => this.markForNotification(task), cleanupPendingByParent: (task) => this.cleanupPendingByParent(task), notifyParentSession: (task) => this.notifyParentSession(task) })
return resumeBackgroundTask({ input, findBySession: (id) => this.findBySession(id), client: this.client, concurrencyManager: this.concurrencyManager, pendingByParent: this.pendingByParent, startPolling: () => this.startPolling(), markForNotification: (task) => this.markForNotification(task), cleanupPendingByParent: (task) => this.cleanupPendingByParent(task), notifyParentSession: (task) => this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)) })
}
getTask(id: string): BackgroundTask | undefined { return this.tasks.get(id) }
@@ -94,7 +95,7 @@ export class BackgroundManager {
}
async cancelTask(taskId: string, options?: { source?: string; reason?: string; abortSession?: boolean; skipNotification?: boolean }): Promise<boolean> {
return cancelBackgroundTask({ taskId, options, tasks: this.tasks, queuesByKey: this.queuesByKey, completionTimers: this.completionTimers, idleDeferralTimers: this.idleDeferralTimers, concurrencyManager: this.concurrencyManager, client: this.client, cleanupPendingByParent: (task) => this.cleanupPendingByParent(task), markForNotification: (task) => this.markForNotification(task), notifyParentSession: (task) => this.notifyParentSession(task) })
return cancelBackgroundTask({ taskId, options, tasks: this.tasks, queuesByKey: this.queuesByKey, completionTimers: this.completionTimers, idleDeferralTimers: this.idleDeferralTimers, concurrencyManager: this.concurrencyManager, client: this.client, cleanupPendingByParent: (task) => this.cleanupPendingByParent(task), markForNotification: (task) => this.markForNotification(task), notifyParentSession: (task) => this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)) })
}
handleEvent(event: { type: string; properties?: Record<string, unknown> }): void {
@@ -102,13 +103,14 @@ export class BackgroundManager {
}
shutdown(): void {
this.notificationQueueByParent.clear()
shutdownBackgroundManager({ shutdownTriggered: this.shutdownTriggered, stopPolling: () => this.stopPolling(), tasks: this.tasks, client: this.client, onShutdown: this.onShutdown, concurrencyManager: this.concurrencyManager, completionTimers: this.completionTimers, idleDeferralTimers: this.idleDeferralTimers, notifications: this.notifications, pendingByParent: this.pendingByParent, queuesByKey: this.queuesByKey, processingKeys: this.processingKeys, unregisterProcessCleanup: () => this.unregisterProcessCleanup() })
}
private getConcurrencyKeyFromInput(input: LaunchInput): string { return input.model ? `${input.model.providerID}/${input.model.modelID}` : input.agent }
private async processKey(key: string): Promise<void> { await processConcurrencyKeyQueue({ key, queuesByKey: this.queuesByKey, processingKeys: this.processingKeys, concurrencyManager: this.concurrencyManager, startTask: (item) => this.startTask(item) }) }
private async startTask(item: QueueItem): Promise<void> {
await startQueuedTask({ item, client: this.client, defaultDirectory: this.directory, tmuxEnabled: this.tmuxEnabled, onSubagentSessionCreated: this.onSubagentSessionCreated, startPolling: () => this.startPolling(), getConcurrencyKeyFromInput: (i) => this.getConcurrencyKeyFromInput(i), concurrencyManager: this.concurrencyManager, findBySession: (id) => this.findBySession(id), markForNotification: (task) => this.markForNotification(task), cleanupPendingByParent: (task) => this.cleanupPendingByParent(task), notifyParentSession: (task) => this.notifyParentSession(task) })
await startQueuedTask({ item, client: this.client, defaultDirectory: this.directory, tmuxEnabled: this.tmuxEnabled, onSubagentSessionCreated: this.onSubagentSessionCreated, startPolling: () => this.startPolling(), getConcurrencyKeyFromInput: (i) => this.getConcurrencyKeyFromInput(i), concurrencyManager: this.concurrencyManager, findBySession: (id) => this.findBySession(id), markForNotification: (task) => this.markForNotification(task), cleanupPendingByParent: (task) => this.cleanupPendingByParent(task), notifyParentSession: (task) => this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)) })
}
private startPolling(): void {
@@ -126,17 +128,38 @@ export class BackgroundManager {
pruneStaleState({ tasks: this.tasks, notifications: this.notifications, concurrencyManager: this.concurrencyManager, cleanupPendingByParent: (task) => this.cleanupPendingByParent(task), clearNotificationsForTask: (id) => this.clearNotificationsForTask(id) })
}
private async checkAndInterruptStaleTasks(): Promise<void> {
await checkAndInterruptStaleTasks({ tasks: this.tasks.values(), client: this.client, config: this.config, concurrencyManager: this.concurrencyManager, notifyParentSession: (task) => this.notifyParentSession(task) })
await checkAndInterruptStaleTasks({ tasks: this.tasks.values(), client: this.client, config: this.config, concurrencyManager: this.concurrencyManager, notifyParentSession: (task) => this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)) })
}
private hasRunningTasks(): boolean { return hasRunningTasks(this.tasks.values()) }
private async tryCompleteTask(task: BackgroundTask, source: string): Promise<boolean> {
return tryCompleteBackgroundTask({ task, source, concurrencyManager: this.concurrencyManager, idleDeferralTimers: this.idleDeferralTimers, client: this.client, markForNotification: (t) => this.markForNotification(t), cleanupPendingByParent: (t) => this.cleanupPendingByParent(t), notifyParentSession: (t) => this.notifyParentSession(t) })
return tryCompleteBackgroundTask({ task, source, concurrencyManager: this.concurrencyManager, idleDeferralTimers: this.idleDeferralTimers, client: this.client, markForNotification: (t) => this.markForNotification(t), cleanupPendingByParent: (t) => this.cleanupPendingByParent(t), notifyParentSession: (t) => this.enqueueNotificationForParent(t.parentSessionID, () => this.notifyParentSession(t)) })
}
private async notifyParentSession(task: BackgroundTask): Promise<void> {
await notifyParentSessionInternal({ task, tasks: this.tasks, pendingByParent: this.pendingByParent, completionTimers: this.completionTimers, clearNotificationsForTask: (id) => this.clearNotificationsForTask(id), client: this.client })
}
private enqueueNotificationForParent(parentSessionID: string | undefined, operation: () => Promise<void>): Promise<void> {
if (!parentSessionID) return operation()
const previous = this.notificationQueueByParent.get(parentSessionID) ?? Promise.resolve()
const current = previous
.catch(() => {})
.then(operation)
this.notificationQueueByParent.set(parentSessionID, current)
void current
.finally(() => {
if (this.notificationQueueByParent.get(parentSessionID) === current) {
this.notificationQueueByParent.delete(parentSessionID)
}
})
.catch(() => {})
return current
}
private async validateSessionHasOutput(sessionID: string): Promise<boolean> { return validateSessionHasOutput(this.client, sessionID) }
private async checkSessionTodos(sessionID: string): Promise<boolean> { return checkSessionTodos(this.client, sessionID) }
private clearNotificationsForTask(taskId: string): void { clearNotificationsForTask(this.notifications, taskId) }
@@ -0,0 +1,138 @@
import { describe, test, expect } from "bun:test"
import { loadBuiltinCommands } from "./commands"
import { HANDOFF_TEMPLATE } from "./templates/handoff"
import type { BuiltinCommandName } from "./types"
describe("loadBuiltinCommands", () => {
test("should include handoff command in loaded commands", () => {
//#given
const disabledCommands: BuiltinCommandName[] = []
//#when
const commands = loadBuiltinCommands(disabledCommands)
//#then
expect(commands.handoff).toBeDefined()
expect(commands.handoff.name).toBe("handoff")
})
test("should exclude handoff when disabled", () => {
//#given
const disabledCommands: BuiltinCommandName[] = ["handoff"]
//#when
const commands = loadBuiltinCommands(disabledCommands)
//#then
expect(commands.handoff).toBeUndefined()
})
test("should include handoff template content in command template", () => {
//#given - no disabled commands
//#when
const commands = loadBuiltinCommands()
//#then
expect(commands.handoff.template).toContain(HANDOFF_TEMPLATE)
})
test("should include session context variables in handoff template", () => {
//#given - no disabled commands
//#when
const commands = loadBuiltinCommands()
//#then
expect(commands.handoff.template).toContain("$SESSION_ID")
expect(commands.handoff.template).toContain("$TIMESTAMP")
expect(commands.handoff.template).toContain("$ARGUMENTS")
})
test("should have correct description for handoff", () => {
//#given - no disabled commands
//#when
const commands = loadBuiltinCommands()
//#then
expect(commands.handoff.description).toContain("context summary")
})
})
describe("HANDOFF_TEMPLATE", () => {
test("should include session reading instruction", () => {
//#given - the template string
//#when / #then
expect(HANDOFF_TEMPLATE).toContain("session_read")
})
test("should include compaction-style sections in output format", () => {
//#given - the template string
//#when / #then
expect(HANDOFF_TEMPLATE).toContain("USER REQUESTS (AS-IS)")
expect(HANDOFF_TEMPLATE).toContain("EXPLICIT CONSTRAINTS")
})
test("should include programmatic context gathering instructions", () => {
//#given - the template string
//#when / #then
expect(HANDOFF_TEMPLATE).toContain("todoread")
expect(HANDOFF_TEMPLATE).toContain("git diff")
expect(HANDOFF_TEMPLATE).toContain("git status")
})
test("should include context extraction format", () => {
//#given - the template string
//#when / #then
expect(HANDOFF_TEMPLATE).toContain("WORK COMPLETED")
expect(HANDOFF_TEMPLATE).toContain("CURRENT STATE")
expect(HANDOFF_TEMPLATE).toContain("PENDING TASKS")
expect(HANDOFF_TEMPLATE).toContain("KEY FILES")
expect(HANDOFF_TEMPLATE).toContain("IMPORTANT DECISIONS")
expect(HANDOFF_TEMPLATE).toContain("CONTEXT FOR CONTINUATION")
expect(HANDOFF_TEMPLATE).toContain("GOAL")
})
test("should enforce first person perspective", () => {
//#given - the template string
//#when / #then
expect(HANDOFF_TEMPLATE).toContain("first person perspective")
})
test("should limit key files to 10", () => {
//#given - the template string
//#when / #then
expect(HANDOFF_TEMPLATE).toContain("Maximum 10 files")
})
test("should instruct plain text format without markdown", () => {
//#given - the template string
//#when / #then
expect(HANDOFF_TEMPLATE).toContain("Plain text with bullets")
expect(HANDOFF_TEMPLATE).toContain("No markdown headers")
})
test("should include user instructions for new session", () => {
//#given - the template string
//#when / #then
expect(HANDOFF_TEMPLATE).toContain("new session")
expect(HANDOFF_TEMPLATE).toContain("opencode")
})
test("should not contain emojis", () => {
//#given - the template string
//#when / #then
const emojiRegex = /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2702}-\u{27B0}\u{24C2}-\u{1F251}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u
expect(emojiRegex.test(HANDOFF_TEMPLATE)).toBe(false)
})
})
+17
View File
@@ -5,6 +5,7 @@ import { RALPH_LOOP_TEMPLATE, CANCEL_RALPH_TEMPLATE } from "./templates/ralph-lo
import { STOP_CONTINUATION_TEMPLATE } from "./templates/stop-continuation"
import { REFACTOR_TEMPLATE } from "./templates/refactor"
import { START_WORK_TEMPLATE } from "./templates/start-work"
import { HANDOFF_TEMPLATE } from "./templates/handoff"
const BUILTIN_COMMAND_DEFINITIONS: Record<BuiltinCommandName, Omit<CommandDefinition, "name">> = {
"init-deep": {
@@ -77,6 +78,22 @@ $ARGUMENTS
${STOP_CONTINUATION_TEMPLATE}
</command-instruction>`,
},
handoff: {
description: "(builtin) Create a detailed context summary for continuing work in a new session",
template: `<command-instruction>
${HANDOFF_TEMPLATE}
</command-instruction>
<session-context>
Session ID: $SESSION_ID
Timestamp: $TIMESTAMP
</session-context>
<user-request>
$ARGUMENTS
</user-request>`,
argumentHint: "[goal]",
},
}
export function loadBuiltinCommands(
@@ -0,0 +1,177 @@
export const HANDOFF_TEMPLATE = `# Handoff Command
## Purpose
Use /handoff when:
- The current session context is getting too long and quality is degrading
- You want to start fresh while preserving essential context from this session
- The context window is approaching capacity
This creates a detailed context summary that can be used to continue work in a new session.
---
# PHASE 0: VALIDATE REQUEST
Before proceeding, confirm:
- [ ] There is meaningful work or context in this session to preserve
- [ ] The user wants to create a handoff summary (not just asking about it)
If the session is nearly empty or has no meaningful context, inform the user there is nothing substantial to hand off.
---
# PHASE 1: GATHER PROGRAMMATIC CONTEXT
Execute these tools to gather concrete data:
1. session_read({ session_id: "$SESSION_ID" }) — full session history
2. todoread() — current task progress
3. Bash({ command: "git diff --stat HEAD~10..HEAD" }) — recent file changes
4. Bash({ command: "git status --porcelain" }) — uncommitted changes
Suggested execution order:
\`\`\`
session_read({ session_id: "$SESSION_ID" })
todoread()
Bash({ command: "git diff --stat HEAD~10..HEAD" })
Bash({ command: "git status --porcelain" })
\`\`\`
Analyze the gathered outputs to understand:
- What the user asked for (exact wording)
- What work was completed
- What tasks remain incomplete (include todo state)
- What decisions were made
- What files were modified or discussed (include git diff/stat + status)
- What patterns, constraints, or preferences were established
---
# PHASE 2: EXTRACT CONTEXT
Write the context summary from first person perspective ("I did...", "I told you...").
Focus on:
- Capabilities and behavior, not file-by-file implementation details
- What matters for continuing the work
- Avoiding excessive implementation details (variable names, storage keys, constants) unless critical
- USER REQUESTS (AS-IS) must be verbatim (do not paraphrase)
- EXPLICIT CONSTRAINTS must be verbatim only (do not invent)
Questions to consider when extracting:
- What did I just do or implement?
- What instructions did I already give which are still relevant (e.g. follow patterns in the codebase)?
- What files did I tell you are important or that I am working on?
- Did I provide a plan or spec that should be included?
- What did I already tell you that is important (libraries, patterns, constraints, preferences)?
- What important technical details did I discover (APIs, methods, patterns)?
- What caveats, limitations, or open questions did I find?
---
# PHASE 3: FORMAT OUTPUT
Generate a handoff summary using this exact format:
\`\`\`
HANDOFF CONTEXT
===============
USER REQUESTS (AS-IS)
---------------------
- [Exact verbatim user requests - NOT paraphrased]
GOAL
----
[One sentence describing what should be done next]
WORK COMPLETED
--------------
- [First person bullet points of what was done]
- [Include specific file paths when relevant]
- [Note key implementation decisions]
CURRENT STATE
-------------
- [Current state of the codebase or task]
- [Build/test status if applicable]
- [Any environment or configuration state]
PENDING TASKS
-------------
- [Tasks that were planned but not completed]
- [Next logical steps to take]
- [Any blockers or issues encountered]
- [Include current todo state from todoread()]
KEY FILES
---------
- [path/to/file1] - [brief role description]
- [path/to/file2] - [brief role description]
(Maximum 10 files, prioritized by importance)
- (Include files from git diff/stat and git status)
IMPORTANT DECISIONS
-------------------
- [Technical decisions that were made and why]
- [Trade-offs that were considered]
- [Patterns or conventions established]
EXPLICIT CONSTRAINTS
--------------------
- [Verbatim constraints only - from user or existing AGENTS.md]
- If none, write: None
CONTEXT FOR CONTINUATION
------------------------
- [What the next session needs to know to continue]
- [Warnings or gotchas to be aware of]
- [References to documentation if relevant]
\`\`\`
Rules for the summary:
- Plain text with bullets
- No markdown headers with # (use the format above with dashes)
- No bold, italic, or code fences within content
- Use workspace-relative paths for files
- Keep it focused - only include what matters for continuation
- Pick an appropriate length based on complexity
- USER REQUESTS (AS-IS) and EXPLICIT CONSTRAINTS must be verbatim only
---
# PHASE 4: PROVIDE INSTRUCTIONS
After generating the summary, instruct the user:
\`\`\`
---
TO CONTINUE IN A NEW SESSION:
1. Press 'n' in OpenCode TUI to open a new session, or run 'opencode' in a new terminal
2. Paste the HANDOFF CONTEXT above as your first message
3. Add your request: "Continue from the handoff context above. [Your next task]"
The new session will have all context needed to continue seamlessly.
\`\`\`
---
# IMPORTANT CONSTRAINTS
- DO NOT attempt to programmatically create new sessions (no API available to agents)
- DO provide a self-contained summary that works without access to this session
- DO include workspace-relative file paths
- DO NOT include sensitive information (API keys, credentials, secrets)
- DO NOT exceed 10 files in the KEY FILES section
- DO keep the GOAL section to a single sentence or short paragraph
---
# EXECUTE NOW
Begin by gathering programmatic context, then synthesize the handoff summary.
`
+1 -1
View File
@@ -1,6 +1,6 @@
import type { CommandDefinition } from "../claude-code-command-loader"
export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph" | "ulw-loop" | "refactor" | "start-work" | "stop-continuation"
export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph" | "ulw-loop" | "refactor" | "start-work" | "stop-continuation" | "handoff"
export interface BuiltinCommandConfig {
disabled_commands?: BuiltinCommandName[]
@@ -20,6 +20,7 @@ describe("getSystemMcpServerNames", () => {
})
afterEach(() => {
mock.restore()
rmSync(TEST_DIR, { recursive: true, force: true })
})