diff --git a/apps/backend/internal/agent/lifecycle/session.go b/apps/backend/internal/agent/lifecycle/session.go
index 8209ce2d8c..16fdec76d7 100644
--- a/apps/backend/internal/agent/lifecycle/session.go
+++ b/apps/backend/internal/agent/lifecycle/session.go
@@ -15,6 +15,7 @@ import (
"github.com/kandev/kandev/internal/common/appctx"
"github.com/kandev/kandev/internal/common/constants"
"github.com/kandev/kandev/internal/common/logger"
+ "github.com/kandev/kandev/internal/sysprompt"
v1 "github.com/kandev/kandev/pkg/api/v1"
"github.com/kandev/kandev/pkg/agent"
)
@@ -185,16 +186,7 @@ func (sm *SessionManager) getResumeContextPrompt(agentConfig *registry.AgentType
// injectKandevContext prepends the Kandev system prompt and session context to the user's prompt.
// This provides consistent guidance to the agent about using Kandev tools and the current session IDs.
func (sm *SessionManager) injectKandevContext(taskID, sessionID, prompt string) string {
- return fmt.Sprintf(`
-IMPORTANT KANDEV INSTRUCTIONS:
-- When you have questions for the user, use the ask_user_question_kandev MCP tool to ask them directly.
-- When you need to create or update a plan for a task, use the Kandev MCP plan tools (plan_get, plan_update, etc.).
-- Kandev Task ID: %s
-- Kandev Session ID: %s
-- Always use these IDs when calling Kandev MCP tools that require task_id or session_id parameters.
-
-
-%s`, taskID, sessionID, prompt)
+ return sysprompt.InjectKandevContext(taskID, sessionID, prompt)
}
// loadSession loads an existing session via ACP session/load
diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go
index b0ee765bfb..9d1b63614c 100644
--- a/apps/backend/internal/orchestrator/task_operations.go
+++ b/apps/backend/internal/orchestrator/task_operations.go
@@ -12,6 +12,7 @@ import (
"github.com/kandev/kandev/internal/orchestrator/dto"
"github.com/kandev/kandev/internal/orchestrator/executor"
"github.com/kandev/kandev/internal/orchestrator/queue"
+ "github.com/kandev/kandev/internal/sysprompt"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -218,12 +219,6 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s
return execution, nil
}
-// SystemTagStart marks the beginning of system-injected content that should be hidden from the UI.
-const SystemTagStart = ""
-
-// SystemTagEnd marks the end of system-injected content that should be hidden from the UI.
-const SystemTagEnd = ""
-
// buildWorkflowPrompt constructs the effective prompt using workflow step configuration.
// It combines: step.PromptPrefix + basePrompt + step.PromptSuffix
// If step.PlanMode is true, it also prepends the plan mode prefix.
@@ -235,12 +230,12 @@ func (s *Service) buildWorkflowPrompt(basePrompt string, step *WorkflowStep, tas
// Apply plan mode prefix if enabled (wrapped in system tags)
if step.PlanMode {
- parts = append(parts, wrapSystemContent(PlanModePrefix))
+ parts = append(parts, sysprompt.Wrap(sysprompt.PlanMode))
}
// Add prompt prefix if set (with interpolation, wrapped in system tags)
if step.PromptPrefix != "" {
- parts = append(parts, wrapSystemContent(interpolatePrompt(step.PromptPrefix, taskID)))
+ parts = append(parts, sysprompt.Wrap(sysprompt.InterpolatePlaceholders(step.PromptPrefix, taskID)))
}
// Add base prompt (user's actual message - not wrapped)
@@ -248,26 +243,12 @@ func (s *Service) buildWorkflowPrompt(basePrompt string, step *WorkflowStep, tas
// Add prompt suffix if set (with interpolation, wrapped in system tags)
if step.PromptSuffix != "" {
- parts = append(parts, wrapSystemContent(interpolatePrompt(step.PromptSuffix, taskID)))
+ parts = append(parts, sysprompt.Wrap(sysprompt.InterpolatePlaceholders(step.PromptSuffix, taskID)))
}
return strings.Join(parts, "\n\n")
}
-// wrapSystemContent wraps content in tags to mark it as system-injected.
-func wrapSystemContent(content string) string {
- return SystemTagStart + content + SystemTagEnd
-}
-
-// interpolatePrompt replaces placeholders in prompt templates with actual values.
-// Supported placeholders:
-// - {task_id} - the task ID
-func interpolatePrompt(template string, taskID string) string {
- result := template
- result = strings.ReplaceAll(result, "{task_id}", taskID)
- return result
-}
-
// ResumeTaskSession restarts a specific task session using its stored worktree.
func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID string) (*executor.TaskExecution, error) {
s.logger.Debug("resuming task session",
@@ -544,10 +525,6 @@ func (s *Service) StopSession(ctx context.Context, sessionID string, reason stri
return s.executor.Stop(ctx, sessionID, reason, force)
}
-// PlanModePrefix is prepended to prompts when plan mode is enabled.
-// This instructs agents to analyze and plan before making changes.
-const PlanModePrefix = "[PLAN MODE: Analyze and plan before making changes. Do not execute yet.]\n\n"
-
// PromptTask sends a follow-up prompt to a running agent for a task session.
// If planMode is true, a plan mode prefix is prepended to the prompt.
func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prompt string, model string, planMode bool) (*PromptResult, error) {
@@ -564,7 +541,7 @@ func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prom
// Apply plan mode prefix if enabled
effectivePrompt := prompt
if planMode {
- effectivePrompt = PlanModePrefix + prompt
+ effectivePrompt = sysprompt.InjectPlanMode(prompt)
}
// Check if session is in a review step - if so, move back to the previous step
diff --git a/apps/backend/internal/sysprompt/sysprompt.go b/apps/backend/internal/sysprompt/sysprompt.go
new file mode 100644
index 0000000000..4739339f27
--- /dev/null
+++ b/apps/backend/internal/sysprompt/sysprompt.go
@@ -0,0 +1,78 @@
+// Package sysprompt provides centralized system prompts and utilities for
+// injecting system-level instructions into agent conversations.
+//
+// All system prompts are wrapped in tags to mark them as
+// system-injected content that can be stripped when displaying to users.
+package sysprompt
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+// System tag constants for marking system-injected content.
+const (
+ // TagStart marks the beginning of system-injected content.
+ TagStart = ""
+ // TagEnd marks the end of system-injected content.
+ TagEnd = ""
+)
+
+// systemTagRegex matches ... content including the tags.
+var systemTagRegex = regexp.MustCompile(`[\s\S]*?\s*`)
+
+// StripSystemContent removes all ... blocks from text.
+// This is used to hide system-injected content from the frontend UI.
+func StripSystemContent(text string) string {
+ return systemTagRegex.ReplaceAllString(text, "")
+}
+
+// Wrap wraps content in tags to mark it as system-injected.
+func Wrap(content string) string {
+ return TagStart + content + TagEnd
+}
+
+// PlanMode is the system prompt prepended when plan mode is enabled.
+// It instructs agents to analyze and plan without using writing/destructive tools.
+const PlanMode = `PLAN MODE ACTIVE - READ-ONLY RESTRICTIONS:
+You are in plan mode. You MUST NOT use any writing, modifying, or destructive tools.
+This includes but is not limited to: file writes, file deletes, git commits, shell commands that modify state.
+You CAN use read-only tools (file reads, searches, code analysis) and the Kandev plan MCP tools (plan_get, plan_update, plan_item_update, etc.) if needed to create or update the task plan.
+Focus on analyzing the request and creating a detailed plan. This restriction applies to THIS PROMPT ONLY.`
+
+// KandevContext is the system prompt that provides Kandev-specific instructions
+// and session context to agents. Use FormatKandevContext to inject task/session IDs.
+const KandevContext = `IMPORTANT KANDEV INSTRUCTIONS:
+- When you have questions for the user, use the ask_user_question_kandev MCP tool to ask them directly.
+- When you need to create or update a plan for a task, use the Kandev MCP plan tools (plan_get, plan_update, etc.).
+- Kandev Task ID: %s
+- Kandev Session ID: %s
+- Always use these IDs when calling Kandev MCP tools that require task_id or session_id parameters.`
+
+// FormatKandevContext returns the Kandev context prompt with task and session IDs injected.
+func FormatKandevContext(taskID, sessionID string) string {
+ return fmt.Sprintf(KandevContext, taskID, sessionID)
+}
+
+// InjectKandevContext prepends the Kandev system prompt and session context to a user's prompt.
+// The system content is wrapped in tags.
+func InjectKandevContext(taskID, sessionID, prompt string) string {
+ return Wrap(FormatKandevContext(taskID, sessionID)) + "\n\n" + prompt
+}
+
+// InjectPlanMode prepends the plan mode system prompt to a user's prompt.
+// The system content is wrapped in tags.
+func InjectPlanMode(prompt string) string {
+ return Wrap(PlanMode) + "\n\n" + prompt
+}
+
+// InterpolatePlaceholders replaces placeholders in prompt templates with actual values.
+// Supported placeholders:
+// - {task_id} - the task ID
+func InterpolatePlaceholders(template string, taskID string) string {
+ result := template
+ result = strings.ReplaceAll(result, "{task_id}", taskID)
+ return result
+}
+
diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go
index 01d26c12fd..557d6b3383 100644
--- a/apps/backend/internal/task/models/models.go
+++ b/apps/backend/internal/task/models/models.go
@@ -1,22 +1,12 @@
package models
import (
- "regexp"
"time"
+ "github.com/kandev/kandev/internal/sysprompt"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
-// systemTagRegex matches ... content including the tags.
-// This is used to strip system-injected content from messages before displaying to users.
-var systemTagRegex = regexp.MustCompile(`[\s\S]*?\s*`)
-
-// StripSystemContent removes all ... blocks from text.
-// This is used to hide workflow step prompt modifications (prefix/suffix) from the frontend UI.
-func StripSystemContent(text string) string {
- return systemTagRegex.ReplaceAllString(text, "")
-}
-
// ListMessagesOptions defines pagination options for listing messages
type ListMessagesOptions struct {
Limit int
@@ -152,7 +142,7 @@ func (m *Message) ToAPI() *v1.Message {
TurnID: m.TurnID,
AuthorType: string(m.AuthorType),
AuthorID: m.AuthorID,
- Content: StripSystemContent(m.Content),
+ Content: sysprompt.StripSystemContent(m.Content),
Type: messageType,
Metadata: m.Metadata,
RequestsInput: m.RequestsInput,
diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go
index 252d9f561f..3152e46627 100644
--- a/apps/backend/internal/task/service/service.go
+++ b/apps/backend/internal/task/service/service.go
@@ -18,6 +18,7 @@ import (
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
+ "github.com/kandev/kandev/internal/sysprompt"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository"
"github.com/kandev/kandev/internal/worktree"
@@ -2479,7 +2480,7 @@ func (s *Service) publishMessageEvent(ctx context.Context, eventType string, mes
"turn_id": message.TurnID,
"author_type": string(message.AuthorType),
"author_id": message.AuthorID,
- "content": models.StripSystemContent(message.Content),
+ "content": sysprompt.StripSystemContent(message.Content),
"type": messageType,
"requests_input": message.RequestsInput,
"created_at": message.CreatedAt.Format(time.RFC3339),
diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx
index 79f1d32791..cc8f017a20 100644
--- a/apps/web/components/task/task-chat-panel.tsx
+++ b/apps/web/components/task/task-chat-panel.tsx
@@ -2,6 +2,8 @@
import { useCallback, useEffect, useRef, useState, memo } from 'react';
import { getWebSocketClient } from '@/lib/ws/connection';
+import { useAppStore } from '@/components/state-provider';
+import { getLocalStorage } from '@/lib/local-storage';
import { useSessionMessages } from '@/hooks/domains/session/use-session-messages';
import { useSettingsData } from '@/hooks/domains/settings/use-settings-data';
import { useSessionState } from '@/hooks/domains/session/use-session-state';
@@ -36,7 +38,6 @@ export const TaskChatPanel = memo(function TaskChatPanel({
onRequestChangesTooltipDismiss,
onOpenFileAtLine,
}: TaskChatPanelProps) {
- const [planModeEnabled, setPlanModeEnabled] = useState(false);
const [isSending, setIsSending] = useState(false);
const lastAgentMessageCountRef = useRef(0);
@@ -55,6 +56,30 @@ export const TaskChatPanel = memo(function TaskChatPanel({
isAgentBusy,
} = useSessionState(sessionId);
+ // Plan mode state from store (persisted per session)
+ const planModeEnabled = useAppStore((state) =>
+ resolvedSessionId ? (state.chatInput.planModeBySessionId[resolvedSessionId] ?? false) : false
+ );
+ const setPlanMode = useAppStore((state) => state.setPlanMode);
+ const handlePlanModeChange = useCallback(
+ (enabled: boolean) => {
+ if (resolvedSessionId) {
+ setPlanMode(resolvedSessionId, enabled);
+ }
+ },
+ [resolvedSessionId, setPlanMode]
+ );
+
+ // Initialize plan mode from localStorage on mount
+ useEffect(() => {
+ if (resolvedSessionId) {
+ const stored = getLocalStorage(`plan-mode-${resolvedSessionId}`, false);
+ if (stored) {
+ setPlanMode(resolvedSessionId, stored);
+ }
+ }
+ }, [resolvedSessionId, setPlanMode]);
+
// Fetch messages for this session
const { messages, isLoading: messagesLoading } = useSessionMessages(resolvedSessionId);
@@ -153,15 +178,10 @@ export const TaskChatPanel = memo(function TaskChatPanel({
if (reviewComments && reviewComments.length > 0) {
markCommentsSent(reviewComments.map((c) => c.id));
}
-
- // Reset plan mode after sending - it's a per-message instruction
- if (planModeEnabled) {
- setPlanModeEnabled(false);
- }
} finally {
setIsSending(false);
}
- }, [isSending, planModeEnabled, onSend, handleSendMessage, markCommentsSent]);
+ }, [isSending, onSend, handleSendMessage, markCommentsSent]);
return (
@@ -194,7 +214,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
taskTitle={task?.title}
taskDescription={taskDescription ?? ''}
planModeEnabled={planModeEnabled}
- onPlanModeChange={setPlanModeEnabled}
+ onPlanModeChange={handlePlanModeChange}
isAgentBusy={isAgentBusy}
isStarting={isStarting}
isSending={isSending}
diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts
index b2eb233837..7653be95e3 100644
--- a/apps/web/lib/state/slices/ui/types.ts
+++ b/apps/web/lib/state/slices/ui/types.ts
@@ -36,6 +36,10 @@ export type MobileSessionState = {
isTaskSwitcherOpen: boolean;
};
+export type ChatInputState = {
+ planModeBySessionId: Record;
+};
+
export type UISliceState = {
previewPanel: PreviewPanelState;
rightPanel: RightPanelState;
@@ -43,6 +47,7 @@ export type UISliceState = {
connection: ConnectionState;
mobileKanban: MobileKanbanState;
mobileSession: MobileSessionState;
+ chatInput: ChatInputState;
};
export type UISliceActions = {
@@ -59,6 +64,7 @@ export type UISliceActions = {
setMobileKanbanMenuOpen: (open: boolean) => void;
setMobileSessionPanel: (sessionId: string, panel: MobileSessionPanel) => void;
setMobileSessionTaskSwitcherOpen: (open: boolean) => void;
+ setPlanMode: (sessionId: string, enabled: boolean) => void;
};
export type UISlice = UISliceState & UISliceActions;
diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts
index 0de8aee519..24aa4c84b6 100644
--- a/apps/web/lib/state/slices/ui/ui-slice.ts
+++ b/apps/web/lib/state/slices/ui/ui-slice.ts
@@ -16,6 +16,7 @@ export const defaultUIState: UISliceState = {
connection: { status: 'disconnected', error: null },
mobileKanban: { activeColumnIndex: 0, isMenuOpen: false },
mobileSession: { activePanelBySessionId: {}, isTaskSwitcherOpen: false },
+ chatInput: { planModeBySessionId: {} },
};
export const createUISlice: StateCreator = (
@@ -82,4 +83,9 @@ export const createUISlice: StateCreator {
draft.mobileSession.isTaskSwitcherOpen = open;
}),
+ setPlanMode: (sessionId, enabled) =>
+ set((draft) => {
+ draft.chatInput.planModeBySessionId[sessionId] = enabled;
+ setLocalStorage(`plan-mode-${sessionId}`, enabled);
+ }),
});
diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts
index f5d3e4e7a6..9ecb1de211 100644
--- a/apps/web/lib/state/store.ts
+++ b/apps/web/lib/state/store.ts
@@ -149,6 +149,7 @@ export type AppState = {
connection: typeof defaultUIState['connection'];
mobileKanban: typeof defaultUIState['mobileKanban'];
mobileSession: typeof defaultUIState['mobileSession'];
+ chatInput: typeof defaultUIState['chatInput'];
// Actions from all slices
hydrate: (state: Partial, options?: HydrationOptions) => void;
@@ -201,6 +202,7 @@ export type AppState = {
setMobileKanbanMenuOpen: (open: boolean) => void;
setMobileSessionPanel: (sessionId: string, panel: import('./slices/ui/types').MobileSessionPanel) => void;
setMobileSessionTaskSwitcherOpen: (open: boolean) => void;
+ setPlanMode: (sessionId: string, enabled: boolean) => void;
setMessages: (
sessionId: string,
messages: Message[],
@@ -300,6 +302,7 @@ const defaultState = {
connection: defaultUIState.connection,
mobileKanban: defaultUIState.mobileKanban,
mobileSession: defaultUIState.mobileSession,
+ chatInput: defaultUIState.chatInput,
};
function mergeInitialState(initialState?: Partial): typeof defaultState {
@@ -351,6 +354,7 @@ function mergeInitialState(initialState?: Partial): typeof defaultStat
connection: { ...defaultState.connection, ...initialState.connection },
mobileKanban: { ...defaultState.mobileKanban, ...initialState.mobileKanban },
mobileSession: { ...defaultState.mobileSession, ...initialState.mobileSession },
+ chatInput: { ...defaultState.chatInput, ...initialState.chatInput },
};
}
@@ -413,6 +417,7 @@ export function createAppStore(initialState?: Partial) {
connection: merged.connection,
mobileKanban: merged.mobileKanban,
mobileSession: merged.mobileSession,
+ chatInput: merged.chatInput,
// Add hydrate method
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hydrate: (state, options) => set((draft) => hydrateState(draft as any, state, options)),