@f5-sales-demo/xcsh 22.7.2 → 22.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import { HerdrClient } from "../herdr/client";
2
2
  import { HerdrInteractionBridge } from "../herdr/interactions";
3
+ import { coordinateSessionTitle } from "../utils/title-generator";
3
4
  /**
4
5
  * AgentSession - Core abstraction for agent lifecycle and session management.
5
6
  *
@@ -131,7 +132,6 @@ import { getCurrentThemeName, theme } from "../modes/theme/theme";
131
132
  import type { PlanModeState } from "../plan-mode/state";
132
133
  import autoHandoffThresholdFocusPrompt from "../prompts/system/auto-handoff-threshold-focus.md" with { type: "text" };
133
134
  import defaultModePrompt from "../prompts/system/default-mode-active.md" with { type: "text" };
134
- import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
135
135
  import handoffDocumentPrompt from "../prompts/system/handoff-document.md" with { type: "text" };
136
136
  import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
137
137
  import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
@@ -621,8 +621,7 @@ export class AgentSession {
621
621
  #retryPromise: Promise<void> | undefined = undefined;
622
622
  #retryResolve: (() => void) | undefined = undefined;
623
623
  #activeRetryFallback: ActiveRetryFallbackState | undefined = undefined;
624
- // Todo completion reminder state
625
- #todoReminderCount = 0;
624
+ // Session progress tracking
626
625
  #todoPhases: TodoPhase[] = [];
627
626
  #todoClearTimers = new Map<string, Timer>();
628
627
  #toolChoiceQueue = new ToolChoiceQueue();
@@ -1589,7 +1588,7 @@ export class AgentSession {
1589
1588
  if (toolName === "todo_write" && !isError && Array.isArray(details?.phases)) {
1590
1589
  this.setTodoPhases(details.phases);
1591
1590
  }
1592
- if (toolName === "todo_write" && isError) {
1591
+ if (toolName === "todo_write" && isError && !this.#planModeState?.enabled) {
1593
1592
  const errorText = content?.find(part => part.type === "text")?.text;
1594
1593
  const reminderText = [
1595
1594
  "<system-reminder>",
@@ -1667,7 +1666,7 @@ export class AgentSession {
1667
1666
  const compactionTask = this.#checkCompaction(msg);
1668
1667
  this.#trackPostPromptTask(compactionTask);
1669
1668
  await compactionTask;
1670
- // Check for incomplete todos only after a final assistant stop, not intermediate tool-use turns.
1669
+ // Name only completed exchanges, never intermediate tool-use turns.
1671
1670
  const hasToolCalls = msg.content.some(content => content.type === "toolCall");
1672
1671
  if (hasToolCalls) {
1673
1672
  return;
@@ -1676,7 +1675,7 @@ export class AgentSession {
1676
1675
  if (this.#enforceRewindBeforeYield()) {
1677
1676
  return;
1678
1677
  }
1679
- await this.#checkTodoCompletion();
1678
+ if (this.#promptInFlightCount === 0) void coordinateSessionTitle(this);
1680
1679
  }
1681
1680
  }
1682
1681
  };
@@ -3447,14 +3446,6 @@ export class AgentSession {
3447
3446
  }
3448
3447
  }
3449
3448
 
3450
- // Skip eager todo prelude when the user has already queued a directive
3451
- const pendingDirectives = this.#toolChoiceQueue.inspect();
3452
- const hasPendingUserDirective = pendingDirectives.includes("user-force");
3453
- const eagerTodoPrelude =
3454
- !options?.synthetic && !hasPendingUserDirective && !pendingDirectives.includes("context-selection")
3455
- ? this.#createEagerTodoPrelude(expandedText)
3456
- : undefined;
3457
-
3458
3449
  const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
3459
3450
  if (options?.images) {
3460
3451
  userContent.push(...options.images);
@@ -3488,22 +3479,7 @@ export class AgentSession {
3488
3479
  }
3489
3480
  }
3490
3481
 
3491
- if (eagerTodoPrelude) {
3492
- this.#toolChoiceQueue.pushOnce(eagerTodoPrelude.toolChoice, {
3493
- label: "eager-todo",
3494
- });
3495
- }
3496
-
3497
- try {
3498
- await this.#promptWithMessage(message, expandedText, {
3499
- ...options,
3500
- prependMessages: eagerTodoPrelude ? [eagerTodoPrelude.message] : undefined,
3501
- });
3502
- } finally {
3503
- // Clean up residual eager-todo directive if the prompt never consumed it
3504
- // (e.g., compaction aborted, validation failed).
3505
- this.#toolChoiceQueue.removeByLabel("eager-todo");
3506
- }
3482
+ await this.#promptWithMessage(message, expandedText, options);
3507
3483
  }
3508
3484
 
3509
3485
  async promptCustomMessage<T = unknown>(
@@ -3847,9 +3823,6 @@ export class AgentSession {
3847
3823
  this.#flushPendingBashMessages();
3848
3824
  this.#flushPendingPythonMessages();
3849
3825
 
3850
- // Reset todo reminder count on new user prompt
3851
- this.#todoReminderCount = 0;
3852
-
3853
3826
  // Classified F5 XC API/schema intent is discovered locally before any
3854
3827
  // compaction or provider inference. A QMD/index error is deliberately
3855
3828
  // terminal for this turn so the model cannot substitute web search or a guess.
@@ -3996,6 +3969,15 @@ export class AgentSession {
3996
3969
  if (!options?.skipPostPromptRecoveryWait) {
3997
3970
  await this.#waitForPostPromptRecovery();
3998
3971
  }
3972
+ const finalAssistant = this.#findLastAssistantMessage();
3973
+ if (
3974
+ this.#isPromptCurrent(generation) &&
3975
+ finalAssistant &&
3976
+ finalAssistant.stopReason !== "error" &&
3977
+ finalAssistant.stopReason !== "aborted" &&
3978
+ !finalAssistant.content.some(part => part.type === "toolCall")
3979
+ )
3980
+ void coordinateSessionTitle(this);
3999
3981
  } catch (error) {
4000
3982
  this.#turnPhase.settle(this.#findLastAssistantMessage()?.stopReason === "aborted" ? "aborted" : "error");
4001
3983
  settled = true;
@@ -4769,7 +4751,6 @@ export class AgentSession {
4769
4751
  if (this.model) this.sessionManager.appendModelChange(`${this.model.provider}/${this.model.id}`);
4770
4752
  this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
4771
4753
  this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
4772
- this.#todoReminderCount = 0;
4773
4754
  this.#reconnectToAgent();
4774
4755
 
4775
4756
  // Emit session_switch event with reason "new" to hooks
@@ -5662,7 +5643,6 @@ export class AgentSession {
5662
5643
  this.#followUpMessages = [];
5663
5644
  this.#pendingNextTurnMessages = [];
5664
5645
  this.#scheduledHiddenNextTurnGeneration = undefined;
5665
- this.#todoReminderCount = 0;
5666
5646
  } else if (
5667
5647
  this.sessionManager.getSessionId() !== preview.targetSessionId ||
5668
5648
  this.sessionManager.getSessionFile() !== preview.targetSessionFile
@@ -5839,161 +5819,6 @@ export class AgentSession {
5839
5819
  this.#pendingRewindReport = undefined;
5840
5820
  }
5841
5821
 
5842
- #createEagerTodoPrelude(promptText: string): { message: AgentMessage; toolChoice: ToolChoice } | undefined {
5843
- const eagerTodosEnabled = this.settings.get("todo.eager");
5844
- const todosEnabled = this.settings.get("todo.enabled");
5845
- if (!eagerTodosEnabled || !todosEnabled) {
5846
- return undefined;
5847
- }
5848
-
5849
- if (this.#planModeState?.enabled) {
5850
- return undefined;
5851
- }
5852
- if (this.getTodoPhases().length > 0) {
5853
- return undefined;
5854
- }
5855
-
5856
- // Only inject on the first user message of the conversation. Subsequent user
5857
- // turns must not receive the eager todo reminder — they often correct, clarify,
5858
- // or redirect the prior task, and forcing a brand-new todo list there is wrong.
5859
- const hasPriorUserMessage = this.agent.state.messages.some(m => m.role === "user");
5860
- if (hasPriorUserMessage) {
5861
- return undefined;
5862
- }
5863
-
5864
- const trimmedPromptText = promptText.trim();
5865
- if (trimmedPromptText.endsWith("?") || trimmedPromptText.endsWith("!")) {
5866
- return undefined;
5867
- }
5868
- const normalizedPromptText = trimmedPromptText.toLowerCase();
5869
- const startsWithQuestionWord = /^(?:who|what|when|where|why|how)\b/.test(normalizedPromptText);
5870
- const startsWithConversationalQuestion = /^(?:am|are|do|have) you\b/.test(normalizedPromptText);
5871
- const isConversationalGreeting =
5872
- /^(?:hello|hi|hey|thanks|thank you|good (?:morning|afternoon|evening))\b/.test(normalizedPromptText) ||
5873
- /^tell me about yourself\b/.test(normalizedPromptText);
5874
- const hasSubstantiveAction =
5875
- /\b(?:add|build|change|create|debug|deploy|fix|implement|inspect|investigate|modify|refactor|remove|repair|review|run|test|update|verify|write)\b/.test(
5876
- normalizedPromptText,
5877
- );
5878
- if (
5879
- !hasSubstantiveAction &&
5880
- (startsWithQuestionWord || startsWithConversationalQuestion || isConversationalGreeting)
5881
- ) {
5882
- return undefined;
5883
- }
5884
-
5885
- if (!this.agent.state.tools.some(tool => tool.name === "todo_write")) {
5886
- logger.warn("Eager todo enforcement skipped because todo_write is unavailable", {
5887
- activeToolNames: this.agent.state.tools.map(tool => tool.name),
5888
- });
5889
- return undefined;
5890
- }
5891
-
5892
- const todoWriteToolChoice = buildNamedToolChoice("todo_write", this.model);
5893
- if (!todoWriteToolChoice) {
5894
- logger.warn("Eager todo enforcement skipped because the current model does not support forcing todo_write", {
5895
- modelApi: this.model?.api,
5896
- modelId: this.model?.id,
5897
- });
5898
- return undefined;
5899
- }
5900
-
5901
- const eagerTodoReminder = prompt.render(eagerTodoPrompt);
5902
-
5903
- return {
5904
- message: {
5905
- role: "custom",
5906
- customType: "eager-todo-prelude",
5907
- content: eagerTodoReminder,
5908
- display: false,
5909
- attribution: "agent",
5910
- timestamp: Date.now(),
5911
- },
5912
- toolChoice: todoWriteToolChoice,
5913
- };
5914
- }
5915
- /**
5916
- * Check if agent stopped with incomplete todos and prompt to continue.
5917
- */
5918
- async #checkTodoCompletion(): Promise<void> {
5919
- // Skip todo reminders when the most recent turn was driven by an explicit user force —
5920
- // the user wanted exactly that tool, not a follow-up nag about incomplete todos.
5921
- const lastServedLabel = this.#toolChoiceQueue.consumeLastServedLabel();
5922
- if (lastServedLabel === "user-force") {
5923
- return;
5924
- }
5925
-
5926
- const remindersEnabled = this.settings.get("todo.reminders");
5927
- const todosEnabled = this.settings.get("todo.enabled");
5928
- if (!remindersEnabled || !todosEnabled) {
5929
- this.#todoReminderCount = 0;
5930
- return;
5931
- }
5932
-
5933
- const remindersMax = this.settings.get("todo.reminders.max");
5934
- if (this.#todoReminderCount >= remindersMax) {
5935
- logger.debug("Todo completion: max reminders reached", { count: this.#todoReminderCount });
5936
- return;
5937
- }
5938
-
5939
- const phases = this.getTodoPhases();
5940
- if (phases.length === 0) {
5941
- this.#todoReminderCount = 0;
5942
- return;
5943
- }
5944
-
5945
- const incompleteByPhase = phases
5946
- .map(phase => ({
5947
- name: phase.name,
5948
- tasks: phase.tasks
5949
- .filter(
5950
- (task): task is TodoItem & { status: "pending" | "in_progress" } =>
5951
- task.status === "pending" || task.status === "in_progress",
5952
- )
5953
- .map(task => ({ id: task.id, content: task.content, status: task.status })),
5954
- }))
5955
- .filter(phase => phase.tasks.length > 0);
5956
- const incomplete = incompleteByPhase.flatMap(phase => phase.tasks);
5957
- if (incomplete.length === 0) {
5958
- this.#todoReminderCount = 0;
5959
- return;
5960
- }
5961
-
5962
- // Build reminder message
5963
- this.#todoReminderCount++;
5964
- const todoList = incompleteByPhase
5965
- .map(phase => `- ${phase.name}\n${phase.tasks.map(task => ` - ${task.content}`).join("\n")}`)
5966
- .join("\n");
5967
- const reminder =
5968
- `<system-reminder>\n` +
5969
- `You stopped with ${incomplete.length} incomplete todo item(s):\n${todoList}\n\n` +
5970
- `Please continue working on these tasks or mark them complete if finished.\n` +
5971
- `(Reminder ${this.#todoReminderCount}/${remindersMax})\n` +
5972
- `</system-reminder>`;
5973
-
5974
- logger.debug("Todo completion: sending reminder", {
5975
- incomplete: incomplete.length,
5976
- attempt: this.#todoReminderCount,
5977
- });
5978
-
5979
- // Emit event for UI to render notification
5980
- await this.#emitSessionEvent({
5981
- type: "todo_reminder",
5982
- todos: incomplete,
5983
- attempt: this.#todoReminderCount,
5984
- maxAttempts: remindersMax,
5985
- });
5986
-
5987
- // Inject reminder and continue the conversation
5988
- this.agent.appendMessage({
5989
- role: "developer",
5990
- content: [{ type: "text", text: reminder }],
5991
- attribution: "agent",
5992
- timestamp: Date.now(),
5993
- });
5994
- this.#scheduleAgentContinue({ generation: this.#promptGeneration });
5995
- }
5996
-
5997
5822
  /**
5998
5823
  * Attempt context promotion to a larger model.
5999
5824
  * Returns true if promotion succeeded (caller should retry without compacting).
@@ -56,11 +56,17 @@ import { FileSessionStorage, MemorySessionStorage } from "./session-storage";
56
56
  export const CURRENT_SESSION_VERSION = 3;
57
57
  const DRAFT_ONLY_SESSION_MARKER = ".draft-only-session";
58
58
 
59
+ export interface AutomaticTitleState {
60
+ status: "provisional" | "refined";
61
+ exchangeId: string;
62
+ }
63
+
59
64
  export interface SessionHeader {
60
65
  type: "session";
61
66
  version?: number; // v1 sessions don't have this
62
67
  id: string;
63
- title?: string; // Auto-generated title from first message
68
+ title?: string; // Session display name
69
+ automaticTitle?: AutomaticTitleState;
64
70
  titleSource?: "auto" | "user";
65
71
  timestamp: string;
66
72
  cwd: string;
@@ -1497,6 +1503,7 @@ interface SessionManagerStateSnapshot {
1497
1503
 
1498
1504
  export class SessionManager {
1499
1505
  #sessionId: string = "";
1506
+ #titleRevision = 0;
1500
1507
  #sessionName: string | undefined;
1501
1508
  #titleSource: "auto" | "user" | undefined;
1502
1509
  #sessionFile: string | undefined;
@@ -1567,6 +1574,7 @@ export class SessionManager {
1567
1574
  }
1568
1575
 
1569
1576
  restoreState(snapshot: SessionManagerStateSnapshot): void {
1577
+ this.#titleRevision++;
1570
1578
  this.#sessionId = snapshot.sessionId;
1571
1579
  this.#sessionName = snapshot.sessionName;
1572
1580
  this.#titleSource = snapshot.titleSource;
@@ -1599,6 +1607,7 @@ export class SessionManager {
1599
1607
 
1600
1608
  /** Switch to a different session file (used for resume and branching) */
1601
1609
  async setSessionFile(sessionFile: string): Promise<void> {
1610
+ this.#titleRevision++;
1602
1611
  await this.#closePersistWriter();
1603
1612
  this.#persistError = undefined;
1604
1613
  this.#persistErrorReported = false;
@@ -1607,6 +1616,7 @@ export class SessionManager {
1607
1616
  this.#fileEntries = await loadEntriesFromFile(this.#sessionFile, this.storage);
1608
1617
  if (this.#fileEntries.length > 0) {
1609
1618
  const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
1619
+ this.#titleRevision++;
1610
1620
  this.#sessionId = header?.id ?? Snowflake.next();
1611
1621
  this.#sessionName = header?.title;
1612
1622
  this.#titleSource = header?.titleSource;
@@ -1632,6 +1642,7 @@ export class SessionManager {
1632
1642
 
1633
1643
  /** Start a new session. Closes any existing writer first. */
1634
1644
  async newSession(options?: NewSessionOptions, preview?: SessionNewPreview): Promise<string | undefined> {
1645
+ this.#titleRevision++;
1635
1646
  await this.#closePersistWriter();
1636
1647
  return this.#newSessionSync(options, preview);
1637
1648
  }
@@ -1727,6 +1738,7 @@ export class SessionManager {
1727
1738
  this.#persistErrorReported = false;
1728
1739
 
1729
1740
  // Create new session ID and header
1741
+ this.#titleRevision++;
1730
1742
  this.#sessionId = preview.targetSessionId;
1731
1743
  const timestamp = preview.timestamp;
1732
1744
  this.#sessionFile = preview.targetSessionFile;
@@ -1739,6 +1751,7 @@ export class SessionManager {
1739
1751
  id: this.#sessionId,
1740
1752
  title: oldHeader?.title ?? this.#sessionName,
1741
1753
  titleSource: oldHeader?.titleSource ?? this.#titleSource,
1754
+ automaticTitle: oldHeader?.automaticTitle,
1742
1755
  timestamp,
1743
1756
  cwd: this.cwd,
1744
1757
  parentSession: oldSessionId,
@@ -1904,6 +1917,7 @@ export class SessionManager {
1904
1917
  this.#persistChain = Promise.resolve();
1905
1918
  this.#persistError = undefined;
1906
1919
  this.#persistErrorReported = false;
1920
+ this.#titleRevision++;
1907
1921
  this.#sessionId = preview?.targetSessionId ?? Snowflake.next();
1908
1922
  this.#sessionName = options?.title ? SessionManager.#sanitizeName(options.title) || undefined : undefined;
1909
1923
  this.#titleSource = this.#sessionName ? options?.titleSource : undefined;
@@ -2336,6 +2350,25 @@ export class SessionManager {
2336
2350
  return this.#sessionName;
2337
2351
  }
2338
2352
 
2353
+ get titleRevision(): number {
2354
+ return this.#titleRevision;
2355
+ }
2356
+
2357
+ getAutomaticTitleState(): AutomaticTitleState | undefined {
2358
+ return this.getHeader()?.automaticTitle;
2359
+ }
2360
+
2361
+ /** Validate before mutation so late requests cannot overwrite a rename or a session transition. */
2362
+ setAutomaticSessionName(
2363
+ name: string,
2364
+ state: AutomaticTitleState,
2365
+ sessionId: string,
2366
+ revision: number,
2367
+ ): Promise<boolean> {
2368
+ if (sessionId !== this.#sessionId || revision !== this.#titleRevision) return Promise.resolve(false);
2369
+ return this.setSessionName(name, "auto", state);
2370
+ }
2371
+
2339
2372
  /** Strip C0/C1 control characters (includes ESC, so removes ANSI sequences) and collapse whitespace. */
2340
2373
  static #sanitizeName(name: string): string {
2341
2374
  return name
@@ -2349,7 +2382,11 @@ export class SessionManager {
2349
2382
  * @param source - "user" for explicit renames (/rename command, RPC); "auto" for generated titles.
2350
2383
  * Auto-generated titles are silently ignored when the user has already set a name.
2351
2384
  */
2352
- async setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
2385
+ async setSessionName(
2386
+ name: string,
2387
+ source: "auto" | "user" = "auto",
2388
+ automaticTitle?: AutomaticTitleState,
2389
+ ): Promise<boolean> {
2353
2390
  if (name === "New Realtime Voice Chat") return false;
2354
2391
  // User-set names take permanent precedence over auto-generated ones.
2355
2392
  if (this.#titleSource === "user" && source === "auto") return false;
@@ -2357,6 +2394,7 @@ export class SessionManager {
2357
2394
  const sanitized = SessionManager.#sanitizeName(name);
2358
2395
  if (!sanitized) return false;
2359
2396
 
2397
+ this.#titleRevision++;
2360
2398
  this.#sessionName = sanitized;
2361
2399
  this.#titleSource = source;
2362
2400
 
@@ -2365,6 +2403,7 @@ export class SessionManager {
2365
2403
  if (header) {
2366
2404
  header.title = sanitized;
2367
2405
  header.titleSource = source;
2406
+ header.automaticTitle = source === "auto" ? automaticTitle : undefined;
2368
2407
  }
2369
2408
 
2370
2409
  // Update the session file header with the title (if already flushed)
@@ -2911,6 +2950,7 @@ export class SessionManager {
2911
2950
  if (!this.#byId.has(branchFromId)) {
2912
2951
  throw new Error(`Entry ${branchFromId} not found`);
2913
2952
  }
2953
+ this.#titleRevision++;
2914
2954
  this.#leafId = branchFromId;
2915
2955
  }
2916
2956
 
@@ -2920,6 +2960,7 @@ export class SessionManager {
2920
2960
  * Use this when navigating to re-edit the first user message.
2921
2961
  */
2922
2962
  resetLeaf(): void {
2963
+ this.#titleRevision++;
2923
2964
  this.#leafId = null;
2924
2965
  }
2925
2966
 
@@ -2932,6 +2973,7 @@ export class SessionManager {
2932
2973
  if (branchFromId !== null && !this.#byId.has(branchFromId)) {
2933
2974
  throw new Error(`Entry ${branchFromId} not found`);
2934
2975
  }
2976
+ this.#titleRevision++;
2935
2977
  this.#leafId = branchFromId;
2936
2978
  const entry: BranchSummaryEntry = {
2937
2979
  type: "branch_summary",
@@ -3044,6 +3086,7 @@ export class SessionManager {
3044
3086
  }
3045
3087
  this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`);
3046
3088
  this.#fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
3089
+ this.#titleRevision++;
3047
3090
  this.#sessionId = newSessionId;
3048
3091
  this.#sessionFile = newSessionFile;
3049
3092
  this.#flushed = true;
@@ -3067,6 +3110,7 @@ export class SessionManager {
3067
3110
  parentId = labelEntry.id;
3068
3111
  }
3069
3112
  this.#fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
3113
+ this.#titleRevision++;
3070
3114
  this.#sessionId = newSessionId;
3071
3115
  this.#buildIndex();
3072
3116
  return undefined;
@@ -3116,6 +3160,7 @@ export class SessionManager {
3116
3160
  const newHeader = manager.#fileEntries[0] as SessionHeader;
3117
3161
  newHeader.title = sourceHeader?.title;
3118
3162
  newHeader.titleSource = sourceHeader?.titleSource;
3163
+ newHeader.automaticTitle = sourceHeader?.automaticTitle;
3119
3164
  manager.#fileEntries = [newHeader, ...historyEntries];
3120
3165
  manager.#sessionName = newHeader.title;
3121
3166
  manager.#titleSource = newHeader.titleSource;
@@ -3145,6 +3190,7 @@ export class SessionManager {
3145
3190
  const newHeader = manager.#fileEntries[0] as SessionHeader;
3146
3191
  newHeader.title = sourceHeader?.title;
3147
3192
  newHeader.titleSource = sourceHeader?.titleSource;
3193
+ newHeader.automaticTitle = sourceHeader?.automaticTitle;
3148
3194
  manager.#fileEntries = [newHeader, ...historyEntries];
3149
3195
  manager.#sessionName = newHeader.title;
3150
3196
  manager.#titleSource = newHeader.titleSource;
@@ -368,6 +368,11 @@ export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWrit
368
368
  _onUpdate?: AgentToolUpdateCallback<TodoWriteToolDetails>,
369
369
  _context?: AgentToolContext,
370
370
  ): Promise<AgentToolResult<TodoWriteToolDetails>> {
371
+ if (this.session.getPlanModeState?.()?.enabled) {
372
+ throw new Error(
373
+ "todo_write is unavailable in Plan Mode. Discuss the proposed plan in the conversation; execution tracking resumes in Default mode.",
374
+ );
375
+ }
371
376
  const previousPhases = this.session.getTodoPhases?.() ?? [];
372
377
  const current = fileFromPhases(previousPhases);
373
378
  const { file: updated, errors } = applyOps(current, params.ops);