diff --git a/src/features/builtin-commands/commands.test.ts b/src/features/builtin-commands/commands.test.ts
index 668027368..15231bb44 100644
--- a/src/features/builtin-commands/commands.test.ts
+++ b/src/features/builtin-commands/commands.test.ts
@@ -59,6 +59,16 @@ describe("loadBuiltinCommands", () => {
//#then
expect(commands.handoff.description).toContain("context summary")
})
+
+ test("should preassign Sisyphus as the native agent for start-work", () => {
+ //#given - no disabled commands
+
+ //#when
+ const commands = loadBuiltinCommands()
+
+ //#then
+ expect(commands["start-work"].agent).toBe("sisyphus")
+ })
})
describe("loadBuiltinCommands — remove-ai-slops", () => {
diff --git a/src/features/builtin-commands/commands.ts b/src/features/builtin-commands/commands.ts
index e3b0bb52d..3c0f1e432 100644
--- a/src/features/builtin-commands/commands.ts
+++ b/src/features/builtin-commands/commands.ts
@@ -58,7 +58,7 @@ ${REFACTOR_TEMPLATE}
},
"start-work": {
description: "(builtin) Start Sisyphus work session from Prometheus plan",
- agent: "atlas",
+ agent: "sisyphus",
template: `
${START_WORK_TEMPLATE}
diff --git a/src/hooks/start-work/index.test.ts b/src/hooks/start-work/index.test.ts
index af284f6f8..193d83bb8 100644
--- a/src/hooks/start-work/index.test.ts
+++ b/src/hooks/start-work/index.test.ts
@@ -4,6 +4,7 @@ import { join } from "node:path"
import { tmpdir } from "node:os"
import { randomUUID } from "node:crypto"
import { createStartWorkHook } from "./index"
+import { getAgentListDisplayName } from "../../shared/agent-display-names"
import {
writeBoulderState,
clearBoulderState,
@@ -24,6 +25,22 @@ describe("start-work hook", () => {
} as Parameters[0]
}
+ function createStartWorkPrompt(options?: {
+ sessionContext?: string
+ userRequest?: string
+ }): string {
+ const sessionContext = options?.sessionContext ?? ""
+ const userRequest = options?.userRequest ?? ""
+
+ return `
+You are starting a Sisyphus work session.
+
+
+${sessionContext}${userRequest ? `
+
+${userRequest}` : ""}`
+ }
+
beforeEach(() => {
sessionState._resetForTesting()
sessionState.registerAgentName("atlas")
@@ -65,6 +82,24 @@ describe("start-work hook", () => {
expect(output.parts[0].text).toBe("Just a regular message")
})
+ test("should ignore plain session-context blocks without the start-work marker", async () => {
+ // given
+ const hook = createStartWorkHook(createMockPluginInput())
+ const output = {
+ parts: [{ type: "text", text: "Some context here" }],
+ }
+
+ // when
+ await hook["chat.message"](
+ { sessionID: "session-123" },
+ output
+ )
+
+ // then
+ expect(output.parts[0].text).toBe("Some context here")
+ expect(readBoulderState(testDir)).toBeNull()
+ })
+
test("should detect start-work command via session-context tag", async () => {
// given - hook and start-work message
const hook = createStartWorkHook(createMockPluginInput())
@@ -72,7 +107,7 @@ describe("start-work hook", () => {
parts: [
{
type: "text",
- text: "Some context here",
+ text: createStartWorkPrompt({ sessionContext: "Some context here" }),
},
],
}
@@ -102,7 +137,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput())
const output = {
- parts: [{ type: "text", text: "" }],
+ parts: [{ type: "text", text: createStartWorkPrompt() }],
}
// when
@@ -123,7 +158,7 @@ describe("start-work hook", () => {
parts: [
{
type: "text",
- text: "Session: $SESSION_ID",
+ text: createStartWorkPrompt({ sessionContext: "Session: $SESSION_ID" }),
},
],
}
@@ -146,7 +181,7 @@ describe("start-work hook", () => {
parts: [
{
type: "text",
- text: "Time: $TIMESTAMP",
+ text: createStartWorkPrompt({ sessionContext: "Time: $TIMESTAMP" }),
},
],
}
@@ -177,7 +212,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput())
const output = {
- parts: [{ type: "text", text: "" }],
+ parts: [{ type: "text", text: createStartWorkPrompt() }],
}
// when
@@ -205,7 +240,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput())
const output = {
- parts: [{ type: "text", text: "" }],
+ parts: [{ type: "text", text: createStartWorkPrompt() }],
}
// when
@@ -233,7 +268,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput())
const output = {
- parts: [{ type: "text", text: "" }],
+ parts: [{ type: "text", text: createStartWorkPrompt() }],
}
// when
@@ -274,9 +309,7 @@ describe("start-work hook", () => {
parts: [
{
type: "text",
- text: `
-new-plan
-`,
+ text: createStartWorkPrompt({ userRequest: "new-plan" }),
},
],
}
@@ -306,9 +339,7 @@ describe("start-work hook", () => {
parts: [
{
type: "text",
- text: `
-my-feature-plan ultrawork
-`,
+ text: createStartWorkPrompt({ userRequest: "my-feature-plan ultrawork" }),
},
],
}
@@ -337,9 +368,7 @@ describe("start-work hook", () => {
parts: [
{
type: "text",
- text: `
-api-refactor ulw
-`,
+ text: createStartWorkPrompt({ userRequest: "api-refactor ulw" }),
},
],
}
@@ -368,9 +397,7 @@ describe("start-work hook", () => {
parts: [
{
type: "text",
- text: `
-feature-implementation
-`,
+ text: createStartWorkPrompt({ userRequest: "feature-implementation" }),
},
],
}
@@ -394,7 +421,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput())
const output = {
- parts: [{ type: "text", text: "" }],
+ parts: [{ type: "text", text: createStartWorkPrompt() }],
}
// when
@@ -408,12 +435,12 @@ describe("start-work hook", () => {
updateSpy.mockRestore()
})
- test("should stamp the outgoing message with Atlas so follow-up events keep the handoff", async () => {
+ test("should stamp the outgoing message with Atlas list key so follow-up events keep the handoff", async () => {
// given
const hook = createStartWorkHook(createMockPluginInput())
const output = {
message: {} as Record,
- parts: [{ type: "text", text: "" }],
+ parts: [{ type: "text", text: createStartWorkPrompt() }],
}
// when
@@ -423,7 +450,7 @@ describe("start-work hook", () => {
)
// then
- expect(output.message.agent).toBe("Atlas (Plan Executor)")
+ expect(output.message.agent).toBe(getAgentListDisplayName("atlas"))
})
test("should keep the current agent when Atlas is unavailable", async () => {
@@ -435,7 +462,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput())
const output = {
message: {} as Record,
- parts: [{ type: "text", text: "" }],
+ parts: [{ type: "text", text: createStartWorkPrompt() }],
}
// when
@@ -463,7 +490,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput())
const output = {
message: {} as Record,
- parts: [{ type: "text", text: "" }],
+ parts: [{ type: "text", text: createStartWorkPrompt() }],
}
// when
@@ -498,7 +525,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput())
const output = {
message: {} as Record,
- parts: [{ type: "text", text: "" }],
+ parts: [{ type: "text", text: createStartWorkPrompt() }],
}
// when
@@ -532,7 +559,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput())
const output = {
- parts: [{ type: "text", text: "" }],
+ parts: [{ type: "text", text: createStartWorkPrompt() }],
}
// when
@@ -553,7 +580,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput())
const output = {
- parts: [{ type: "text", text: "\n--worktree /validated/worktree\n" }],
+ parts: [{ type: "text", text: createStartWorkPrompt({ userRequest: "--worktree /validated/worktree" }) }],
}
// when
@@ -575,7 +602,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput())
const output = {
- parts: [{ type: "text", text: "\n--worktree /valid/wt\n" }],
+ parts: [{ type: "text", text: createStartWorkPrompt({ userRequest: "--worktree /valid/wt" }) }],
}
// when
@@ -595,7 +622,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput())
const output = {
- parts: [{ type: "text", text: "\n--worktree /nonexistent/wt\n" }],
+ parts: [{ type: "text", text: createStartWorkPrompt({ userRequest: "--worktree /nonexistent/wt" }) }],
}
// when
@@ -624,7 +651,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput())
const output = {
- parts: [{ type: "text", text: "\n--worktree /new/wt\n" }],
+ parts: [{ type: "text", text: createStartWorkPrompt({ userRequest: "--worktree /new/wt" }) }],
}
// when
@@ -651,7 +678,7 @@ describe("start-work hook", () => {
const hook = createStartWorkHook(createMockPluginInput())
const output = {
- parts: [{ type: "text", text: "" }],
+ parts: [{ type: "text", text: createStartWorkPrompt() }],
}
// when
diff --git a/src/hooks/start-work/start-work-hook.ts b/src/hooks/start-work/start-work-hook.ts
index 7f9f0bcdb..d0445d7f1 100644
--- a/src/hooks/start-work/start-work-hook.ts
+++ b/src/hooks/start-work/start-work-hook.ts
@@ -11,18 +11,33 @@ import {
clearBoulderState,
} from "../../features/boulder-state"
import { log } from "../../shared/logger"
-import { getAgentConfigKey, getAgentDisplayName } from "../../shared/agent-display-names"
-import { getSessionAgent, isAgentRegistered, updateSessionAgent } from "../../features/claude-code-session-state"
+import {
+ getAgentConfigKey,
+ getAgentDisplayName,
+ getAgentListDisplayName,
+} from "../../shared/agent-display-names"
+import {
+ getSessionAgent,
+ isAgentRegistered,
+ updateSessionAgent,
+} from "../../features/claude-code-session-state"
import { detectWorktreePath } from "./worktree-detector"
import { parseUserRequest } from "./parse-user-request"
export const HOOK_NAME = "start-work" as const
+const START_WORK_TEMPLATE_MARKER = "You are starting a Sisyphus work session."
interface StartWorkHookInput {
sessionID: string
messageID?: string
}
+interface StartWorkCommandExecuteBeforeInput {
+ sessionID: string
+ command: string
+ arguments: string
+}
+
interface StartWorkHookOutput {
message?: Record
parts: Array<{ type: string; text?: string }>
@@ -67,61 +82,76 @@ function resolveWorktreeContext(
}
export function createStartWorkHook(ctx: PluginInput) {
- return {
- "chat.message": async (input: StartWorkHookInput, output: StartWorkHookOutput): Promise => {
- const parts = output.parts
- const promptText =
- parts
- ?.filter((p) => p.type === "text" && p.text)
- .map((p) => p.text)
- .join("\n")
- .trim() || ""
+ const processStartWork = async (
+ input: StartWorkHookInput,
+ output: StartWorkHookOutput,
+ ): Promise => {
+ const parts = output.parts
+ const promptText =
+ parts
+ ?.filter((p) => p.type === "text" && p.text)
+ .map((p) => p.text)
+ .join("\n")
+ .trim() || ""
- if (!promptText.includes("")) return
+ if (
+ !promptText.includes("")
+ || !promptText.includes(START_WORK_TEMPLATE_MARKER)
+ ) {
+ return
+ }
- log(`[${HOOK_NAME}] Processing start-work command`, { sessionID: input.sessionID })
- const currentSessionAgent = getSessionAgent(input.sessionID)
- const activeAgent = isAgentRegistered("atlas")
- ? "atlas"
- : currentSessionAgent && getAgentConfigKey(currentSessionAgent) !== "prometheus"
- ? currentSessionAgent
+ log(`[${HOOK_NAME}] Processing start-work command`, { sessionID: input.sessionID })
+ const currentSessionAgent = getSessionAgent(input.sessionID)
+ const currentSessionAgentKey = currentSessionAgent
+ ? getAgentConfigKey(currentSessionAgent)
+ : undefined
+ const activeAgent = currentSessionAgent
+ && currentSessionAgentKey
+ && currentSessionAgentKey !== "prometheus"
+ && currentSessionAgentKey !== "atlas"
+ ? currentSessionAgent
+ : isAgentRegistered("atlas")
+ ? "atlas"
: "sisyphus"
- const activeAgentDisplayName = getAgentDisplayName(activeAgent)
- updateSessionAgent(input.sessionID, activeAgent)
- if (output.message) {
- output.message["agent"] = activeAgentDisplayName
- }
+ const activeAgentDisplayName = activeAgent === "atlas"
+ ? getAgentListDisplayName(activeAgent)
+ : getAgentDisplayName(activeAgent)
+ updateSessionAgent(input.sessionID, activeAgent)
+ if (output.message) {
+ output.message["agent"] = activeAgentDisplayName
+ }
- const existingState = readBoulderState(ctx.directory)
- const sessionId = input.sessionID
- const timestamp = new Date().toISOString()
+ const existingState = readBoulderState(ctx.directory)
+ const sessionId = input.sessionID
+ const timestamp = new Date().toISOString()
- const { planName: explicitPlanName, explicitWorktreePath } = parseUserRequest(promptText)
- const { worktreePath, block: worktreeBlock } = resolveWorktreeContext(explicitWorktreePath)
+ const { planName: explicitPlanName, explicitWorktreePath } = parseUserRequest(promptText)
+ const { worktreePath, block: worktreeBlock } = resolveWorktreeContext(explicitWorktreePath)
- let contextInfo = ""
+ let contextInfo = ""
- if (explicitPlanName) {
- log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: input.sessionID })
+ if (explicitPlanName) {
+ log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: input.sessionID })
- const allPlans = findPrometheusPlans(ctx.directory)
- const matchedPlan = findPlanByName(allPlans, explicitPlanName)
+ const allPlans = findPrometheusPlans(ctx.directory)
+ const matchedPlan = findPlanByName(allPlans, explicitPlanName)
- if (matchedPlan) {
- const progress = getPlanProgress(matchedPlan)
+ if (matchedPlan) {
+ const progress = getPlanProgress(matchedPlan)
- if (progress.isComplete) {
- contextInfo = `
+ if (progress.isComplete) {
+ contextInfo = `
## Plan Already Complete
The requested plan "${getPlanName(matchedPlan)}" has been completed.
All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
- } else {
- if (existingState) clearBoulderState(ctx.directory)
- const newState = createBoulderState(matchedPlan, sessionId, activeAgent, worktreePath)
- writeBoulderState(ctx.directory, newState)
+ } else {
+ if (existingState) clearBoulderState(ctx.directory)
+ const newState = createBoulderState(matchedPlan, sessionId, activeAgent, worktreePath)
+ writeBoulderState(ctx.directory, newState)
- contextInfo = `
+ contextInfo = `
## Auto-Selected Plan
**Plan**: ${getPlanName(matchedPlan)}
@@ -132,18 +162,18 @@ All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
${worktreeBlock}
boulder.json has been created. Read the plan and begin execution.`
- }
- } else {
- const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete)
- if (incompletePlans.length > 0) {
- const planList = incompletePlans
- .map((p, i) => {
- const prog = getPlanProgress(p)
- return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}`
- })
- .join("\n")
+ }
+ } else {
+ const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete)
+ if (incompletePlans.length > 0) {
+ const planList = incompletePlans
+ .map((p, i) => {
+ const prog = getPlanProgress(p)
+ return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}`
+ })
+ .join("\n")
- contextInfo = `
+ contextInfo = `
## Plan Not Found
Could not find a plan matching "${explicitPlanName}".
@@ -152,39 +182,39 @@ Available incomplete plans:
${planList}
Ask the user which plan to work on.`
- } else {
- contextInfo = `
+ } else {
+ contextInfo = `
## Plan Not Found
Could not find a plan matching "${explicitPlanName}".
No incomplete plans available. Create a new plan with: /plan "your task"`
- }
}
- } else if (existingState) {
- const progress = getPlanProgress(existingState.active_plan)
+ }
+ } else if (existingState) {
+ const progress = getPlanProgress(existingState.active_plan)
- if (!progress.isComplete) {
- const effectiveWorktree = worktreePath ?? existingState.worktree_path
- const sessionAlreadyTracked = existingState.session_ids.includes(sessionId)
- const updatedSessions = sessionAlreadyTracked
- ? existingState.session_ids
- : [...existingState.session_ids, sessionId]
- const shouldRewriteState = existingState.agent !== activeAgent || worktreePath !== undefined
+ if (!progress.isComplete) {
+ const effectiveWorktree = worktreePath ?? existingState.worktree_path
+ const sessionAlreadyTracked = existingState.session_ids.includes(sessionId)
+ const updatedSessions = sessionAlreadyTracked
+ ? existingState.session_ids
+ : [...existingState.session_ids, sessionId]
+ const shouldRewriteState = existingState.agent !== activeAgent || worktreePath !== undefined
- if (shouldRewriteState) {
- writeBoulderState(ctx.directory, {
- ...existingState,
- agent: activeAgent,
- ...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}),
- session_ids: updatedSessions,
- })
- } else if (!sessionAlreadyTracked) {
- appendSessionId(ctx.directory, sessionId)
- }
+ if (shouldRewriteState) {
+ writeBoulderState(ctx.directory, {
+ ...existingState,
+ agent: activeAgent,
+ ...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}),
+ session_ids: updatedSessions,
+ })
+ } else if (!sessionAlreadyTracked) {
+ appendSessionId(ctx.directory, sessionId)
+ }
- const worktreeDisplay = effectiveWorktree ? createWorktreeActiveBlock(effectiveWorktree) : worktreeBlock
+ const worktreeDisplay = effectiveWorktree ? createWorktreeActiveBlock(effectiveWorktree) : worktreeBlock
- contextInfo = `
+ contextInfo = `
## Active Work Session Found
**Status**: RESUMING existing work
@@ -197,41 +227,41 @@ ${worktreeDisplay}
The current session (${sessionId}) has been added to session_ids.
Read the plan file and continue from the first unchecked task.`
- } else {
- contextInfo = `
+ } else {
+ contextInfo = `
## Previous Work Complete
The previous plan (${existingState.plan_name}) has been completed.
Looking for new plans...`
- }
}
+ }
- if (
- (!existingState && !explicitPlanName) ||
- (existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)
- ) {
- const plans = findPrometheusPlans(ctx.directory)
- const incompletePlans = plans.filter((p) => !getPlanProgress(p).isComplete)
+ if (
+ (!existingState && !explicitPlanName) ||
+ (existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)
+ ) {
+ const plans = findPrometheusPlans(ctx.directory)
+ const incompletePlans = plans.filter((p) => !getPlanProgress(p).isComplete)
- if (plans.length === 0) {
- contextInfo += `
+ if (plans.length === 0) {
+ contextInfo += `
## No Plans Found
No Prometheus plan files found at .sisyphus/plans/
Use Prometheus to create a work plan first: /plan "your task"`
- } else if (incompletePlans.length === 0) {
- contextInfo += `
+ } else if (incompletePlans.length === 0) {
+ contextInfo += `
## All Plans Complete
All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your task"`
- } else if (incompletePlans.length === 1) {
- const planPath = incompletePlans[0]
- const progress = getPlanProgress(planPath)
- const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
- writeBoulderState(ctx.directory, newState)
+ } else if (incompletePlans.length === 1) {
+ const planPath = incompletePlans[0]
+ const progress = getPlanProgress(planPath)
+ const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
+ writeBoulderState(ctx.directory, newState)
- contextInfo += `
+ contextInfo += `
## Auto-Selected Plan
@@ -243,16 +273,16 @@ All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your ta
${worktreeBlock}
boulder.json has been created. Read the plan and begin execution.`
- } else {
- const planList = incompletePlans
- .map((p, i) => {
- const progress = getPlanProgress(p)
- const modified = new Date(statSync(p).mtimeMs).toISOString()
- return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}`
- })
- .join("\n")
+ } else {
+ const planList = incompletePlans
+ .map((p, i) => {
+ const progress = getPlanProgress(p)
+ const modified = new Date(statSync(p).mtimeMs).toISOString()
+ return `${i + 1}. [${getPlanName(p)}] - Modified: ${modified} - Progress: ${progress.completed}/${progress.total}`
+ })
+ .join("\n")
- contextInfo += `
+ contextInfo += `
## Multiple Plans Found
@@ -265,23 +295,34 @@ ${planList}
Ask the user which plan to work on. Present the options above and wait for their response.
${worktreeBlock}
`
- }
}
+ }
- const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
- if (idx >= 0 && output.parts[idx].text) {
- output.parts[idx].text = output.parts[idx].text
- .replace(/\$SESSION_ID/g, sessionId)
- .replace(/\$TIMESTAMP/g, timestamp)
+ const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
+ if (idx >= 0 && output.parts[idx].text) {
+ output.parts[idx].text = output.parts[idx].text
+ .replace(/\$SESSION_ID/g, sessionId)
+ .replace(/\$TIMESTAMP/g, timestamp)
- output.parts[idx].text += `\n\n---\n${contextInfo}`
- }
+ output.parts[idx].text += `\n\n---\n${contextInfo}`
+ }
- log(`[${HOOK_NAME}] Context injected`, {
- sessionID: input.sessionID,
- hasExistingState: !!existingState,
- worktreePath,
- })
+ log(`[${HOOK_NAME}] Context injected`, {
+ sessionID: input.sessionID,
+ hasExistingState: !!existingState,
+ worktreePath,
+ })
+ }
+
+ return {
+ "chat.message": async (input: StartWorkHookInput, output: StartWorkHookOutput): Promise => {
+ await processStartWork(input, output)
+ },
+ "command.execute.before": async (
+ input: StartWorkCommandExecuteBeforeInput,
+ output: StartWorkHookOutput,
+ ): Promise => {
+ await processStartWork(input, output)
},
}
}
diff --git a/src/plugin-interface.test.ts b/src/plugin-interface.test.ts
new file mode 100644
index 000000000..e9dfee568
--- /dev/null
+++ b/src/plugin-interface.test.ts
@@ -0,0 +1,172 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test"
+import { mkdirSync, rmSync, writeFileSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { randomUUID } from "node:crypto"
+import { createPluginInterface } from "./plugin-interface"
+import { createAutoSlashCommandHook } from "./hooks/auto-slash-command"
+import { createStartWorkHook } from "./hooks/start-work"
+import { getAgentListDisplayName } from "./shared/agent-display-names"
+import { readBoulderState } from "./features/boulder-state"
+import {
+ _resetForTesting,
+ getSessionAgent,
+ registerAgentName,
+ updateSessionAgent,
+} from "./features/claude-code-session-state"
+
+describe("createPluginInterface - command.execute.before", () => {
+ let testDir = ""
+
+ beforeEach(() => {
+ testDir = join(tmpdir(), `plugin-interface-start-work-${randomUUID()}`)
+ mkdirSync(join(testDir, ".sisyphus", "plans"), { recursive: true })
+ writeFileSync(join(testDir, ".sisyphus", "plans", "worker-plan.md"), "# Plan\n- [ ] Task 1")
+ _resetForTesting()
+ registerAgentName("prometheus")
+ registerAgentName("sisyphus")
+ })
+
+ afterEach(() => {
+ _resetForTesting()
+ rmSync(testDir, { recursive: true, force: true })
+ })
+
+ test("executes start-work side effects for native command execution", async () => {
+ // given
+ updateSessionAgent("ses-command-before", "prometheus")
+ const pluginInterface = createPluginInterface({
+ ctx: {
+ directory: testDir,
+ client: { tui: { showToast: async () => {} } },
+ } as never,
+ pluginConfig: {} as never,
+ firstMessageVariantGate: {
+ shouldOverride: () => false,
+ markApplied: () => {},
+ markSessionCreated: () => {},
+ clear: () => {},
+ },
+ managers: {} as never,
+ hooks: {
+ autoSlashCommand: createAutoSlashCommandHook({ skills: [] }),
+ startWork: createStartWorkHook({
+ directory: testDir,
+ client: { tui: { showToast: async () => {} } },
+ } as never),
+ } as never,
+ tools: {},
+ })
+ const output = {
+ parts: [{ type: "text", text: "original" }],
+ }
+
+ // when
+ await pluginInterface["command.execute.before"]?.(
+ {
+ command: "start-work",
+ sessionID: "ses-command-before",
+ arguments: "",
+ },
+ output as never
+ )
+
+ // then
+ expect(pluginInterface["command.execute.before"]).toBeDefined()
+ expect(output.parts[0]?.text).toContain("Auto-Selected Plan")
+ expect(output.parts[0]?.text).toContain("boulder.json has been created")
+ expect(getSessionAgent("ses-command-before")).toBe("sisyphus")
+ expect(readBoulderState(testDir)?.agent).toBe("sisyphus")
+ })
+
+ test("does not run start-work side effects for other native commands with session context", async () => {
+ // given
+ updateSessionAgent("ses-handoff", "prometheus")
+ const pluginInterface = createPluginInterface({
+ ctx: {
+ directory: testDir,
+ client: { tui: { showToast: async () => {} } },
+ } as never,
+ pluginConfig: {} as never,
+ firstMessageVariantGate: {
+ shouldOverride: () => false,
+ markApplied: () => {},
+ markSessionCreated: () => {},
+ clear: () => {},
+ },
+ managers: {} as never,
+ hooks: {
+ autoSlashCommand: createAutoSlashCommandHook({ skills: [] }),
+ startWork: createStartWorkHook({
+ directory: testDir,
+ client: { tui: { showToast: async () => {} } },
+ } as never),
+ } as never,
+ tools: {},
+ })
+ const output = {
+ parts: [{ type: "text", text: "original" }],
+ }
+
+ // when
+ await pluginInterface["command.execute.before"]?.(
+ {
+ command: "handoff",
+ sessionID: "ses-handoff",
+ arguments: "",
+ },
+ output as never
+ )
+
+ // then
+ expect(output.parts[0]?.text).toContain("HANDOFF CONTEXT")
+ expect(readBoulderState(testDir)).toBeNull()
+ expect(getSessionAgent("ses-handoff")).toBe("prometheus")
+ })
+
+ test("switches native start-work to Atlas when Atlas is registered in config", async () => {
+ // given
+ registerAgentName("atlas")
+ updateSessionAgent("ses-command-atlas", "prometheus")
+ const pluginInterface = createPluginInterface({
+ ctx: {
+ directory: testDir,
+ client: { tui: { showToast: async () => {} } },
+ } as never,
+ pluginConfig: {} as never,
+ firstMessageVariantGate: {
+ shouldOverride: () => false,
+ markApplied: () => {},
+ markSessionCreated: () => {},
+ clear: () => {},
+ },
+ managers: {} as never,
+ hooks: {
+ autoSlashCommand: createAutoSlashCommandHook({ skills: [] }),
+ startWork: createStartWorkHook({
+ directory: testDir,
+ client: { tui: { showToast: async () => {} } },
+ } as never),
+ } as never,
+ tools: {},
+ })
+ const output = {
+ message: {} as Record,
+ parts: [{ type: "text", text: "/start-work" }],
+ }
+
+ // when
+ await pluginInterface["chat.message"]?.(
+ {
+ sessionID: "ses-command-atlas",
+ agent: "prometheus",
+ } as never,
+ output as never
+ )
+
+ // then
+ expect(output.message.agent).toBe(getAgentListDisplayName("atlas"))
+ expect(getSessionAgent("ses-command-atlas")).toBe("atlas")
+ expect(readBoulderState(testDir)?.agent).toBe("atlas")
+ })
+})
diff --git a/src/plugin-interface.ts b/src/plugin-interface.ts
index d7d65762d..5bcc0c364 100644
--- a/src/plugin-interface.ts
+++ b/src/plugin-interface.ts
@@ -4,6 +4,7 @@ import type { OhMyOpenCodeConfig } from "./config"
import { createChatParamsHandler } from "./plugin/chat-params"
import { createChatHeadersHandler } from "./plugin/chat-headers"
import { createChatMessageHandler } from "./plugin/chat-message"
+import { createCommandExecuteBeforeHandler } from "./plugin/command-execute-before"
import { createMessagesTransformHandler } from "./plugin/messages-transform"
import { createSystemTransformHandler } from "./plugin/system-transform"
import { createEventHandler } from "./plugin/event"
@@ -42,6 +43,10 @@ export function createPluginInterface(args: {
"chat.headers": createChatHeadersHandler({ ctx }),
+ "command.execute.before": createCommandExecuteBeforeHandler({
+ hooks,
+ }),
+
"chat.message": createChatMessageHandler({
ctx,
pluginConfig,
diff --git a/src/plugin/chat-message.test.ts b/src/plugin/chat-message.test.ts
index e79eb8d2d..1ef58df06 100644
--- a/src/plugin/chat-message.test.ts
+++ b/src/plugin/chat-message.test.ts
@@ -1,7 +1,14 @@
-import { afterEach, describe, test, expect } from "bun:test"
+import { afterEach, beforeEach, describe, test, expect } from "bun:test"
+import { mkdirSync, rmSync, writeFileSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { randomUUID } from "node:crypto"
import { createChatMessageHandler } from "./chat-message"
-import { _resetForTesting, setMainSession, subagentSessions } from "../features/claude-code-session-state"
+import { createAutoSlashCommandHook } from "../hooks/auto-slash-command"
+import { createStartWorkHook } from "../hooks/start-work"
+import { readBoulderState } from "../features/boulder-state"
+import { _resetForTesting, setMainSession, subagentSessions, registerAgentName, updateSessionAgent, getSessionAgent } from "../features/claude-code-session-state"
import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/session-model-state"
type ChatMessagePart = { type: string; text?: string; [key: string]: unknown }
@@ -39,6 +46,55 @@ afterEach(() => {
clearSessionModel("subagent-session")
})
+describe("createChatMessageHandler - /start-work integration", () => {
+ let testDir = ""
+ let originalWorkingDirectory = ""
+
+ beforeEach(() => {
+ testDir = join(tmpdir(), `chat-message-start-work-${randomUUID()}`)
+ originalWorkingDirectory = process.cwd()
+ mkdirSync(join(testDir, ".sisyphus", "plans"), { recursive: true })
+ writeFileSync(join(testDir, ".sisyphus", "plans", "worker-plan.md"), "# Plan\n- [ ] Task 1")
+ process.chdir(testDir)
+ _resetForTesting()
+ registerAgentName("prometheus")
+ registerAgentName("sisyphus")
+ })
+
+ afterEach(() => {
+ process.chdir(originalWorkingDirectory)
+ rmSync(testDir, { recursive: true, force: true })
+ })
+
+ test("falls back to Sisyphus through the full chat.message slash-command path when Atlas is unavailable", async () => {
+ // given
+ updateSessionAgent("test-session", "prometheus")
+ const args = createMockHandlerArgs()
+ args.hooks.autoSlashCommand = createAutoSlashCommandHook({ skills: [] })
+ args.hooks.startWork = createStartWorkHook({
+ directory: testDir,
+ client: { tui: { showToast: async () => {} } },
+ } as never)
+ const handler = createChatMessageHandler(args)
+ const input = createMockInput("prometheus")
+ const output: ChatMessageHandlerOutput = {
+ message: {},
+ parts: [{ type: "text", text: "/start-work" }],
+ }
+
+ // when
+ await handler(input, output)
+
+ // then
+ expect(output.message["agent"]).toBe("Sisyphus (Ultraworker)")
+ expect(output.parts[0].text).toContain("")
+ expect(output.parts[0].text).toContain("Auto-Selected Plan")
+ expect(output.parts[0].text).toContain("boulder.json has been created")
+ expect(getSessionAgent("test-session")).toBe("sisyphus")
+ expect(readBoulderState(testDir)?.agent).toBe("sisyphus")
+ })
+})
+
function createMockInput(agent?: string, model?: { providerID: string; modelID: string }) {
return {
sessionID: "test-session",
diff --git a/src/plugin/command-execute-before.ts b/src/plugin/command-execute-before.ts
new file mode 100644
index 000000000..09f17f7ca
--- /dev/null
+++ b/src/plugin/command-execute-before.ts
@@ -0,0 +1,39 @@
+import type { CreatedHooks } from "../create-hooks"
+
+type CommandExecuteBeforeInput = {
+ command: string
+ sessionID: string
+ arguments: string
+}
+
+type CommandExecuteBeforeOutput = {
+ parts: Array<{ type: string; text?: string; [key: string]: unknown }>
+}
+
+function hasPartsOutput(value: unknown): value is CommandExecuteBeforeOutput {
+ if (typeof value !== "object" || value === null) return false
+ const record = value as Record
+ const parts = record["parts"]
+ return Array.isArray(parts)
+}
+
+export function createCommandExecuteBeforeHandler(args: {
+ hooks: CreatedHooks
+}): (
+ input: CommandExecuteBeforeInput,
+ output: CommandExecuteBeforeOutput,
+) => Promise {
+ const { hooks } = args
+
+ return async (input, output): Promise => {
+ await hooks.autoSlashCommand?.["command.execute.before"]?.(input, output)
+
+ if (
+ hooks.startWork
+ && input.command.toLowerCase() === "start-work"
+ && hasPartsOutput(output)
+ ) {
+ await hooks.startWork["command.execute.before"]?.(input, output)
+ }
+ }
+}