@oh-my-pi/pi-coding-agent 12.19.3 → 13.0.1

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.
Files changed (103) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/package.json +7 -7
  3. package/src/commit/prompts/analysis-system.md +3 -3
  4. package/src/commit/prompts/analysis-user.md +14 -14
  5. package/src/commit/prompts/changelog-system.md +4 -4
  6. package/src/commit/prompts/changelog-user.md +4 -4
  7. package/src/commit/prompts/file-observer-system.md +2 -2
  8. package/src/commit/prompts/file-observer-user.md +2 -2
  9. package/src/commit/prompts/reduce-system.md +4 -4
  10. package/src/commit/prompts/reduce-user.md +6 -6
  11. package/src/commit/prompts/summary-system.md +4 -4
  12. package/src/commit/prompts/summary-user.md +6 -6
  13. package/src/discovery/helpers.ts +13 -1
  14. package/src/internal-urls/docs-index.generated.ts +2 -2
  15. package/src/internal-urls/index.ts +8 -3
  16. package/src/internal-urls/local-protocol.ts +223 -0
  17. package/src/internal-urls/{docs-protocol.ts → pi-protocol.ts} +12 -12
  18. package/src/internal-urls/router.ts +1 -1
  19. package/src/internal-urls/types.ts +1 -1
  20. package/src/ipy/executor.ts +4 -32
  21. package/src/memories/index.ts +1 -1
  22. package/src/modes/controllers/event-controller.ts +4 -4
  23. package/src/modes/interactive-mode.ts +84 -64
  24. package/src/modes/types.ts +11 -3
  25. package/src/modes/utils/ui-helpers.ts +5 -3
  26. package/src/patch/hashline.ts +42 -42
  27. package/src/patch/index.ts +106 -153
  28. package/src/patch/shared.ts +21 -51
  29. package/src/plan-mode/approved-plan.ts +55 -0
  30. package/src/prompts/agents/designer.md +6 -6
  31. package/src/prompts/agents/explore.md +4 -4
  32. package/src/prompts/agents/frontmatter.md +1 -0
  33. package/src/prompts/agents/init.md +10 -10
  34. package/src/prompts/agents/plan.md +6 -6
  35. package/src/prompts/agents/reviewer.md +4 -3
  36. package/src/prompts/agents/task.md +10 -10
  37. package/src/prompts/compaction/branch-summary.md +3 -3
  38. package/src/prompts/compaction/compaction-short-summary.md +7 -7
  39. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  40. package/src/prompts/compaction/compaction-summary.md +5 -5
  41. package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
  42. package/src/prompts/compaction/compaction-update-summary.md +11 -11
  43. package/src/prompts/memories/consolidation.md +5 -5
  44. package/src/prompts/memories/read-path.md +11 -0
  45. package/src/prompts/memories/stage_one_input.md +1 -1
  46. package/src/prompts/memories/stage_one_system.md +5 -5
  47. package/src/prompts/review-request.md +4 -4
  48. package/src/prompts/system/agent-creation-architect.md +21 -21
  49. package/src/prompts/system/agent-creation-user.md +2 -2
  50. package/src/prompts/system/custom-system-prompt.md +6 -6
  51. package/src/prompts/system/plan-mode-active.md +20 -20
  52. package/src/prompts/system/plan-mode-approved.md +9 -7
  53. package/src/prompts/system/plan-mode-reference.md +2 -2
  54. package/src/prompts/system/plan-mode-subagent.md +8 -8
  55. package/src/prompts/system/subagent-submit-reminder.md +5 -5
  56. package/src/prompts/system/subagent-system-prompt.md +9 -9
  57. package/src/prompts/system/subagent-user-prompt.md +3 -5
  58. package/src/prompts/system/summarization-system.md +1 -1
  59. package/src/prompts/system/system-prompt.md +109 -84
  60. package/src/prompts/system/title-system.md +2 -2
  61. package/src/prompts/system/ttsr-interrupt.md +2 -2
  62. package/src/prompts/system/web-search.md +16 -16
  63. package/src/prompts/tools/ask.md +6 -6
  64. package/src/prompts/tools/bash.md +9 -9
  65. package/src/prompts/tools/browser.md +5 -5
  66. package/src/prompts/tools/cancel-job.md +2 -2
  67. package/src/prompts/tools/exit-plan-mode.md +13 -10
  68. package/src/prompts/tools/find.md +2 -2
  69. package/src/prompts/tools/gemini-image.md +7 -7
  70. package/src/prompts/tools/grep.md +4 -3
  71. package/src/prompts/tools/hashline.md +55 -56
  72. package/src/prompts/tools/patch.md +6 -6
  73. package/src/prompts/tools/poll-jobs.md +1 -1
  74. package/src/prompts/tools/python.md +10 -12
  75. package/src/prompts/tools/read.md +2 -12
  76. package/src/prompts/tools/replace.md +7 -7
  77. package/src/prompts/tools/ssh.md +2 -7
  78. package/src/prompts/tools/task.md +48 -38
  79. package/src/prompts/tools/todo-write.md +65 -49
  80. package/src/prompts/tools/web-search.md +2 -2
  81. package/src/prompts/tools/write.md +4 -3
  82. package/src/sdk.ts +11 -9
  83. package/src/session/agent-session.ts +92 -51
  84. package/src/session/artifacts.ts +1 -1
  85. package/src/session/messages.ts +1 -0
  86. package/src/task/agents.ts +1 -0
  87. package/src/task/index.ts +2 -1
  88. package/src/task/render.ts +2 -2
  89. package/src/task/types.ts +1 -0
  90. package/src/tools/bash-interactive.ts +1 -1
  91. package/src/tools/bash-skill-urls.ts +3 -2
  92. package/src/tools/bash.ts +21 -12
  93. package/src/tools/exit-plan-mode.ts +30 -2
  94. package/src/tools/grep.ts +131 -75
  95. package/src/tools/index.ts +13 -3
  96. package/src/tools/path-utils.ts +2 -1
  97. package/src/tools/plan-mode-guard.ts +8 -8
  98. package/src/tools/python.ts +0 -2
  99. package/src/tools/read.ts +2 -2
  100. package/src/tools/todo-write.ts +276 -146
  101. package/src/internal-urls/plan-protocol.ts +0 -95
  102. package/src/modes/components/todo-display.ts +0 -114
  103. package/src/prompts/memories/read_path.md +0 -11
@@ -75,7 +75,7 @@ import { ExtensionToolWrapper } from "../extensibility/extensions/wrapper";
75
75
  import type { HookCommandContext } from "../extensibility/hooks/types";
76
76
  import type { Skill, SkillWarning } from "../extensibility/skills";
77
77
  import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
78
- import { resolvePlanUrlToPath } from "../internal-urls";
78
+ import { resolveLocalUrlToPath } from "../internal-urls";
79
79
  import { executePython as executePythonCommand, type PythonResult } from "../ipy/executor";
80
80
  import { getCurrentThemeName, theme } from "../modes/theme/theme";
81
81
  import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../patch";
@@ -88,7 +88,7 @@ import { closeAllConnections } from "../ssh/connection-manager";
88
88
  import { unmountAll } from "../ssh/sshfs-mount";
89
89
  import { outputMeta } from "../tools/output-meta";
90
90
  import { resolveToCwd } from "../tools/path-utils";
91
- import type { TodoItem } from "../tools/todo-write";
91
+ import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo-write";
92
92
  import { parseCommandArgs } from "../utils/command-args";
93
93
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
94
94
  import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
@@ -135,7 +135,6 @@ export type AgentSessionEvent =
135
135
 
136
136
  /** Listener function for agent session events */
137
137
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
138
-
139
138
  export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
140
139
 
141
140
  export interface AsyncJobSnapshot {
@@ -192,7 +191,7 @@ export interface PromptOptions {
192
191
  streamingBehavior?: "steer" | "followUp";
193
192
  /** Optional tool choice override for the next LLM call. */
194
193
  toolChoice?: ToolChoice;
195
- /** Mark the user message as synthetic (system-injected). */
194
+ /** Send as developer/system message instead of user. Providers that support it use the developer role; others fall back to user. */
196
195
  synthetic?: boolean;
197
196
  }
198
197
 
@@ -310,6 +309,7 @@ export class AgentSession {
310
309
  #pendingNextTurnMessages: CustomMessage[] = [];
311
310
  #planModeState: PlanModeState | undefined;
312
311
  #planReferenceSent = false;
312
+ #planReferencePath = "local://PLAN.md";
313
313
 
314
314
  // Compaction state
315
315
  #compactionAbortController: AbortController | undefined = undefined;
@@ -330,6 +330,7 @@ export class AgentSession {
330
330
 
331
331
  // Todo completion reminder state
332
332
  #todoReminderCount = 0;
333
+ #todoPhases: TodoPhase[] = [];
333
334
 
334
335
  // Bash execution state
335
336
  #bashAbortController: AbortController | undefined = undefined;
@@ -395,6 +396,7 @@ export class AgentSession {
395
396
  this.#forceCopilotAgentInitiator = config.forceCopilotAgentInitiator ?? false;
396
397
  this.#obfuscator = config.obfuscator;
397
398
  this.agent.providerSessionState = this.#providerSessionState;
399
+ this.#syncTodoPhasesFromBranch();
398
400
 
399
401
  // Always subscribe to agent events for internal handling
400
402
  // (session persistence, hooks, auto-compaction, retry logic)
@@ -602,6 +604,7 @@ export class AgentSession {
602
604
  }
603
605
  } else if (
604
606
  event.message.role === "user" ||
607
+ event.message.role === "developer" ||
605
608
  event.message.role === "assistant" ||
606
609
  event.message.role === "toolResult" ||
607
610
  event.message.role === "fileMention"
@@ -638,7 +641,7 @@ export class AgentSession {
638
641
  const { toolName, $normative, toolCallId, details, isError, content } = event.message as {
639
642
  toolName?: string;
640
643
  toolCallId?: string;
641
- details?: { path?: string };
644
+ details?: { path?: string; phases?: TodoPhase[] };
642
645
  $normative?: Record<string, unknown>;
643
646
  isError?: boolean;
644
647
  content?: Array<TextContent | ImageContent>;
@@ -650,14 +653,17 @@ export class AgentSession {
650
653
  if (toolName === "edit" && details?.path) {
651
654
  this.#invalidateFileCacheForPath(details.path);
652
655
  }
656
+ if (toolName === "todo_write" && !isError && Array.isArray(details?.phases)) {
657
+ this.setTodoPhases(details.phases);
658
+ }
653
659
  if (toolName === "todo_write" && isError) {
654
660
  const errorText = content?.find(part => part.type === "text")?.text;
655
661
  const reminderText = [
656
- "<system_reminder>",
662
+ "<system-reminder>",
657
663
  "todo_write failed, so todo progress is not visible to the user.",
658
664
  errorText ? `Failure: ${errorText}` : "Failure: todo_write returned an error.",
659
665
  "Fix the todo payload and call todo_write again before continuing.",
660
- "</system_reminder>",
666
+ "</system-reminder>",
661
667
  ].join("\n");
662
668
  await this.sendCustomMessage(
663
669
  {
@@ -1491,6 +1497,7 @@ export class AgentSession {
1491
1497
  this.#planModeState = state;
1492
1498
  if (state?.enabled) {
1493
1499
  this.#planReferenceSent = false;
1500
+ this.#planReferencePath = state.planFilePath;
1494
1501
  }
1495
1502
  }
1496
1503
 
@@ -1498,6 +1505,10 @@ export class AgentSession {
1498
1505
  this.#planReferenceSent = true;
1499
1506
  }
1500
1507
 
1508
+ setPlanReferencePath(path: string): void {
1509
+ this.#planReferencePath = path;
1510
+ }
1511
+
1501
1512
  /**
1502
1513
  * Inject the plan mode context message into the conversation history.
1503
1514
  */
@@ -1546,10 +1557,10 @@ export class AgentSession {
1546
1557
  if (this.#planModeState?.enabled) return null;
1547
1558
  if (this.#planReferenceSent) return null;
1548
1559
 
1549
- const planFilePath = `plan://${this.sessionManager.getSessionId()}/plan.md`;
1550
- const resolvedPlanPath = resolvePlanUrlToPath(planFilePath, {
1551
- getPlansDirectory: () => this.settings.getPlansDirectory(),
1552
- cwd: this.sessionManager.getCwd(),
1560
+ const planFilePath = this.#planReferencePath;
1561
+ const resolvedPlanPath = resolveLocalUrlToPath(planFilePath, {
1562
+ getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
1563
+ getSessionId: () => this.sessionManager.getSessionId(),
1553
1564
  });
1554
1565
  let planContent: string;
1555
1566
  try {
@@ -1580,19 +1591,19 @@ export class AgentSession {
1580
1591
  async #buildPlanModeMessage(): Promise<CustomMessage | null> {
1581
1592
  const state = this.#planModeState;
1582
1593
  if (!state?.enabled) return null;
1583
- const sessionPlanUrl = `plan://${this.sessionManager.getSessionId()}/plan.md`;
1584
- const resolvedPlanPath = state.planFilePath.startsWith("plan://")
1585
- ? resolvePlanUrlToPath(state.planFilePath, {
1586
- getPlansDirectory: () => this.settings.getPlansDirectory(),
1587
- cwd: this.sessionManager.getCwd(),
1594
+ const sessionPlanUrl = "local://PLAN.md";
1595
+ const resolvedPlanPath = state.planFilePath.startsWith("local://")
1596
+ ? resolveLocalUrlToPath(state.planFilePath, {
1597
+ getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
1598
+ getSessionId: () => this.sessionManager.getSessionId(),
1588
1599
  })
1589
1600
  : resolveToCwd(state.planFilePath, this.sessionManager.getCwd());
1590
- const resolvedSessionPlan = resolvePlanUrlToPath(sessionPlanUrl, {
1591
- getPlansDirectory: () => this.settings.getPlansDirectory(),
1592
- cwd: this.sessionManager.getCwd(),
1601
+ const resolvedSessionPlan = resolveLocalUrlToPath(sessionPlanUrl, {
1602
+ getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
1603
+ getSessionId: () => this.sessionManager.getSessionId(),
1593
1604
  });
1594
1605
  const displayPlanPath =
1595
- state.planFilePath.startsWith("plan://") || resolvedPlanPath !== resolvedSessionPlan
1606
+ state.planFilePath.startsWith("local://") || resolvedPlanPath !== resolvedSessionPlan
1596
1607
  ? state.planFilePath
1597
1608
  : sessionPlanUrl;
1598
1609
 
@@ -1673,16 +1684,11 @@ export class AgentSession {
1673
1684
  userContent.push(...options.images);
1674
1685
  }
1675
1686
 
1676
- await this.#promptWithMessage(
1677
- {
1678
- role: "user",
1679
- content: userContent,
1680
- synthetic: options?.synthetic,
1681
- timestamp: Date.now(),
1682
- },
1683
- expandedText,
1684
- options,
1685
- );
1687
+ const message = options?.synthetic
1688
+ ? { role: "developer" as const, content: userContent, timestamp: Date.now() }
1689
+ : { role: "user" as const, content: userContent, timestamp: Date.now() };
1690
+
1691
+ await this.#promptWithMessage(message, expandedText, options);
1686
1692
  }
1687
1693
 
1688
1694
  async promptCustomMessage<T = unknown>(
@@ -2185,6 +2191,31 @@ export class AgentSession {
2185
2191
  return this.#skillWarnings;
2186
2192
  }
2187
2193
 
2194
+ getTodoPhases(): TodoPhase[] {
2195
+ return this.#cloneTodoPhases(this.#todoPhases);
2196
+ }
2197
+
2198
+ setTodoPhases(phases: TodoPhase[]): void {
2199
+ this.#todoPhases = this.#cloneTodoPhases(phases);
2200
+ }
2201
+
2202
+ #syncTodoPhasesFromBranch(): void {
2203
+ this.setTodoPhases(getLatestTodoPhasesFromEntries(this.sessionManager.getBranch()));
2204
+ }
2205
+
2206
+ #cloneTodoPhases(phases: TodoPhase[]): TodoPhase[] {
2207
+ return phases.map(phase => ({
2208
+ id: phase.id,
2209
+ name: phase.name,
2210
+ tasks: phase.tasks.map(task => ({
2211
+ id: task.id,
2212
+ content: task.content,
2213
+ status: task.status,
2214
+ notes: task.notes,
2215
+ })),
2216
+ }));
2217
+ }
2218
+
2188
2219
  /**
2189
2220
  * Abort current operation and wait for agent to become idle.
2190
2221
  */
@@ -2228,6 +2259,7 @@ export class AgentSession {
2228
2259
  this.agent.reset();
2229
2260
  await this.sessionManager.flush();
2230
2261
  await this.sessionManager.newSession(options);
2262
+ this.setTodoPhases([]);
2231
2263
  this.agent.sessionId = this.sessionManager.getSessionId();
2232
2264
  this.#steeringMessages = [];
2233
2265
  this.#followUpMessages = [];
@@ -2237,6 +2269,7 @@ export class AgentSession {
2237
2269
 
2238
2270
  this.#todoReminderCount = 0;
2239
2271
  this.#planReferenceSent = false;
2272
+ this.#planReferencePath = "local://PLAN.md";
2240
2273
  this.#reconnectToAgent();
2241
2274
 
2242
2275
  // Emit session_switch event with reason "new" to hooks
@@ -2645,6 +2678,7 @@ export class AgentSession {
2645
2678
  await this.sessionManager.rewriteEntries();
2646
2679
  const sessionContext = this.sessionManager.buildSessionContext();
2647
2680
  this.agent.replaceMessages(sessionContext.messages);
2681
+ this.#syncTodoPhasesFromBranch();
2648
2682
  this.#closeCodexProviderSessionsForHistoryRewrite();
2649
2683
  return result;
2650
2684
  }
@@ -2769,6 +2803,7 @@ export class AgentSession {
2769
2803
  const newEntries = this.sessionManager.getEntries();
2770
2804
  const sessionContext = this.sessionManager.buildSessionContext();
2771
2805
  this.agent.replaceMessages(sessionContext.messages);
2806
+ this.#syncTodoPhasesFromBranch();
2772
2807
  this.#closeCodexProviderSessionsForHistoryRewrite();
2773
2808
 
2774
2809
  // Get the saved compaction entry for the hook
@@ -2925,7 +2960,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
2925
2960
 
2926
2961
  try {
2927
2962
  // Send the prompt and wait for completion
2928
- await this.prompt(handoffPrompt, { expandPromptTemplates: false });
2963
+ await this.prompt(handoffPrompt, { expandPromptTemplates: false, synthetic: true });
2929
2964
  await completionPromise;
2930
2965
 
2931
2966
  if (!handoffText || this.#handoffAbortController.signal.aborted) {
@@ -2950,6 +2985,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
2950
2985
  // Rebuild agent messages from session
2951
2986
  const sessionContext = this.sessionManager.buildSessionContext();
2952
2987
  this.agent.replaceMessages(sessionContext.messages);
2988
+ this.#syncTodoPhasesFromBranch();
2953
2989
 
2954
2990
  return { document: handoffText };
2955
2991
  } finally {
@@ -3047,25 +3083,24 @@ Be thorough - include exact file paths, function names, error messages, and tech
3047
3083
  return;
3048
3084
  }
3049
3085
 
3050
- // Load current todos from artifacts
3051
- const sessionFile = this.sessionManager.getSessionFile();
3052
- if (!sessionFile) return;
3053
-
3054
- const todoPath = `${sessionFile.slice(0, -6)}/todos.json`;
3055
-
3056
- let todos: TodoItem[];
3057
- try {
3058
- const data = await Bun.file(todoPath).json();
3059
- todos = data?.todos ?? [];
3060
- } catch (err) {
3061
- if (isEnoent(err)) {
3062
- this.#todoReminderCount = 0;
3063
- }
3086
+ const phases = this.getTodoPhases();
3087
+ if (phases.length === 0) {
3088
+ this.#todoReminderCount = 0;
3064
3089
  return;
3065
3090
  }
3066
3091
 
3067
- // Check for incomplete todos
3068
- const incomplete = todos.filter(t => t.status !== "completed");
3092
+ const incompleteByPhase = phases
3093
+ .map(phase => ({
3094
+ name: phase.name,
3095
+ tasks: phase.tasks
3096
+ .filter(
3097
+ (task): task is TodoItem & { status: "pending" | "in_progress" } =>
3098
+ task.status === "pending" || task.status === "in_progress",
3099
+ )
3100
+ .map(task => ({ id: task.id, content: task.content, status: task.status })),
3101
+ }))
3102
+ .filter(phase => phase.tasks.length > 0);
3103
+ const incomplete = incompleteByPhase.flatMap(phase => phase.tasks);
3069
3104
  if (incomplete.length === 0) {
3070
3105
  this.#todoReminderCount = 0;
3071
3106
  return;
@@ -3073,13 +3108,15 @@ Be thorough - include exact file paths, function names, error messages, and tech
3073
3108
 
3074
3109
  // Build reminder message
3075
3110
  this.#todoReminderCount++;
3076
- const todoList = incomplete.map(t => `- ${t.content}`).join("\n");
3111
+ const todoList = incompleteByPhase
3112
+ .map(phase => `- ${phase.name}\n${phase.tasks.map(task => ` - ${task.content}`).join("\n")}`)
3113
+ .join("\n");
3077
3114
  const reminder =
3078
- `<system_reminder>\n` +
3115
+ `<system-reminder>\n` +
3079
3116
  `You stopped with ${incomplete.length} incomplete todo item(s):\n${todoList}\n\n` +
3080
3117
  `Please continue working on these tasks or mark them complete if finished.\n` +
3081
3118
  `(Reminder ${this.#todoReminderCount}/${remindersMax})\n` +
3082
- `</system_reminder>`;
3119
+ `</system-reminder>`;
3083
3120
 
3084
3121
  logger.debug("Todo completion: sending reminder", {
3085
3122
  incomplete: incomplete.length,
@@ -3096,7 +3133,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3096
3133
 
3097
3134
  // Inject reminder and continue the conversation
3098
3135
  this.agent.appendMessage({
3099
- role: "user",
3136
+ role: "developer",
3100
3137
  content: [{ type: "text", text: reminder }],
3101
3138
  timestamp: Date.now(),
3102
3139
  });
@@ -3482,6 +3519,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3482
3519
  const newEntries = this.sessionManager.getEntries();
3483
3520
  const sessionContext = this.sessionManager.buildSessionContext();
3484
3521
  this.agent.replaceMessages(sessionContext.messages);
3522
+ this.#syncTodoPhasesFromBranch();
3485
3523
  this.#closeCodexProviderSessionsForHistoryRewrite();
3486
3524
 
3487
3525
  // Get the saved compaction entry for the hook
@@ -4054,6 +4092,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
4054
4092
  }
4055
4093
 
4056
4094
  this.agent.replaceMessages(sessionContext.messages);
4095
+ this.#syncTodoPhasesFromBranch();
4057
4096
 
4058
4097
  // Restore model if saved
4059
4098
  const defaultModelStr = sessionContext.models.default;
@@ -4135,6 +4174,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
4135
4174
  } else {
4136
4175
  this.sessionManager.createBranchedSession(selectedEntry.parentId);
4137
4176
  }
4177
+ this.#syncTodoPhasesFromBranch();
4138
4178
  this.agent.sessionId = this.sessionManager.getSessionId();
4139
4179
 
4140
4180
  // Reload messages from entries (works for both file and in-memory mode)
@@ -4303,6 +4343,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
4303
4343
  // Update agent state
4304
4344
  const sessionContext = this.sessionManager.buildSessionContext();
4305
4345
  this.agent.replaceMessages(sessionContext.messages);
4346
+ this.#syncTodoPhasesFromBranch();
4306
4347
 
4307
4348
  // Emit session_tree event
4308
4349
  if (this.#extensionRunner) {
@@ -2,7 +2,7 @@
2
2
  * Session-scoped artifact storage for truncated tool outputs.
3
3
  *
4
4
  * Artifacts are stored in a directory alongside the session file,
5
- * accessible via artifact:// URLs or the $ARTIFACTS environment variable.
5
+ * accessible via artifact:// URLs.
6
6
  */
7
7
  import * as fs from "node:fs/promises";
8
8
  import * as path from "node:path";
@@ -300,6 +300,7 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
300
300
  };
301
301
  }
302
302
  case "user":
303
+ case "developer":
303
304
  case "assistant":
304
305
  return m;
305
306
  case "toolResult":
@@ -22,6 +22,7 @@ interface AgentFrontmatter {
22
22
  spawns?: string;
23
23
  model?: string | string[];
24
24
  thinkingLevel?: string;
25
+ blocking?: boolean;
25
26
  }
26
27
 
27
28
  interface EmbeddedAgentDef {
package/src/task/index.ts CHANGED
@@ -179,7 +179,8 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
179
179
  onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
180
180
  ): Promise<AgentToolResult<TaskToolDetails>> {
181
181
  const asyncEnabled = this.session.settings.get("async.enabled");
182
- if (!asyncEnabled) {
182
+ const selectedAgent = this.#discoveredAgents.find(agent => agent.name === params.agent);
183
+ if (!asyncEnabled || selectedAgent?.blocking === true) {
183
184
  return this.#executeSync(_toolCallId, params, signal, onUpdate);
184
185
  }
185
186
 
@@ -366,9 +366,9 @@ function renderTaskSection(
366
366
  const trimmed = task.trimEnd();
367
367
  if (!expanded || !trimmed) return lines;
368
368
 
369
- // Strip the shared <swarm_context>...</swarm_context> block — it's the same
369
+ // Strip the shared <context>...</context> block — it's the same
370
370
  // across all tasks and just adds noise when expanded.
371
- const stripped = trimmed.replace(/<swarm_context>[\s\S]*?<\/swarm_context>\s*/, "").trimStart();
371
+ const stripped = trimmed.replace(/<context>[\s\S]*?<\/context>\s*/, "").trimStart();
372
372
  if (!stripped) return lines;
373
373
 
374
374
  lines.push(`${continuePrefix}${theme.fg("dim", "Task")}`);
package/src/task/types.ts CHANGED
@@ -127,6 +127,7 @@ export interface AgentDefinition {
127
127
  model?: string[];
128
128
  thinkingLevel?: ThinkingLevel;
129
129
  output?: unknown;
130
+ blocking?: boolean;
130
131
  source: AgentSource;
131
132
  filePath?: string;
132
133
  }
@@ -275,7 +275,7 @@ class BashInteractiveOverlayComponent implements Component {
275
275
  }
276
276
  }
277
277
 
278
- const NO_PAGER_ENV = {
278
+ export const NO_PAGER_ENV = {
279
279
  // Disable pagers so commands don't block on interactive views.
280
280
  PAGER: "cat",
281
281
  GIT_PAGER: "cat",
@@ -22,6 +22,7 @@ interface InternalUrlResolver {
22
22
 
23
23
  export interface InternalUrlExpansionOptions {
24
24
  skills: readonly Skill[];
25
+ noEscape?: boolean;
25
26
  internalRouter?: InternalUrlResolver;
26
27
  }
27
28
 
@@ -152,7 +153,7 @@ export function expandSkillUrls(command: string, skills: readonly Skill[]): stri
152
153
 
153
154
  /**
154
155
  * Expand supported internal URLs in a bash command string to shell-escaped absolute paths.
155
- * Supported schemes: skill://, agent://, artifact://, plan://, memory://, rule://
156
+ * Supported schemes: skill://, agent://, artifact://, memory://, rule://, local://
156
157
  */
157
158
  export async function expandInternalUrls(command: string, options: InternalUrlExpansionOptions): Promise<string> {
158
159
  if (!command.includes("://")) return command;
@@ -169,7 +170,7 @@ export async function expandInternalUrls(command: string, options: InternalUrlEx
169
170
 
170
171
  const url = unquoteToken(token);
171
172
  const resolvedPath = await resolveInternalUrlToPath(url, options.skills, options.internalRouter);
172
- const replacement = shellEscape(resolvedPath);
173
+ const replacement = options.noEscape ? resolvedPath : shellEscape(resolvedPath);
173
174
  expanded = `${expanded.slice(0, index)}${replacement}${expanded.slice(index + token.length)}`;
174
175
  }
175
176
 
package/src/tools/bash.ts CHANGED
@@ -16,10 +16,10 @@ import { DEFAULT_MAX_BYTES, TailBuffer } from "../session/streaming-output";
16
16
  import { renderStatusLine } from "../tui";
17
17
  import { CachedOutputBlock } from "../tui/output-block";
18
18
  import type { ToolSession } from ".";
19
- import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive";
19
+ import { type BashInteractiveResult, NO_PAGER_ENV, runInteractiveBashPty } from "./bash-interactive";
20
20
  import { checkBashInterception } from "./bash-interceptor";
21
21
  import { applyHeadTail } from "./bash-normalize";
22
- import { expandInternalUrls } from "./bash-skill-urls";
22
+ import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls";
23
23
  import { formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
24
24
  import { resolveToCwd } from "./path-utils";
25
25
  import { replaceTabs } from "./render-utils";
@@ -144,6 +144,14 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
144
144
  ): Promise<AgentToolResult<BashToolDetails>> {
145
145
  let command = rawCommand;
146
146
 
147
+ // Extract leading `cd <path> && ...` into cwd when the model ignores the cwd parameter.
148
+ if (!cwd) {
149
+ const cdMatch = command.match(/^cd\s+((?:[^&\\]|\\.)+?)\s*&&\s*/);
150
+ if (cdMatch) {
151
+ cwd = cdMatch[1].trim().replace(/^["']|["']$/g, "");
152
+ command = command.slice(cdMatch[0].length);
153
+ }
154
+ }
147
155
  if (asyncRequested && !this.#asyncEnabled) {
148
156
  throw new ToolError("Async bash execution is disabled. Enable async.enabled to use async mode.");
149
157
  }
@@ -161,10 +169,16 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
161
169
  }
162
170
  }
163
171
 
164
- command = await expandInternalUrls(command, {
172
+ const internalUrlOptions: InternalUrlExpansionOptions = {
165
173
  skills: this.session.skills ?? [],
166
174
  internalRouter: this.session.internalRouter,
167
- });
175
+ };
176
+ command = await expandInternalUrls(command, internalUrlOptions);
177
+
178
+ // Resolve protocol URLs (skill://, agent://, etc.) in extracted cwd.
179
+ if (cwd?.includes("://")) {
180
+ cwd = await expandInternalUrls(cwd, { ...internalUrlOptions, noEscape: true });
181
+ }
168
182
 
169
183
  const commandCwd = cwd ? resolveToCwd(cwd, this.session.cwd) : this.session.cwd;
170
184
  let cwdStat: fs.Stats;
@@ -195,8 +209,6 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
195
209
  "bash",
196
210
  label,
197
211
  async ({ jobId, signal: runSignal, reportProgress }) => {
198
- const artifactsDir = this.session.getArtifactsDir?.();
199
- const extraEnv = artifactsDir ? { ARTIFACTS: artifactsDir } : undefined;
200
212
  const { path: artifactPath, id: artifactId } =
201
213
  (await this.session.allocateOutputArtifact?.("bash")) ?? {};
202
214
  try {
@@ -205,7 +217,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
205
217
  sessionKey: `${this.session.getSessionId?.() ?? ""}:async:${jobId}`,
206
218
  timeout: timeoutMs,
207
219
  signal: runSignal,
208
- env: extraEnv,
220
+ env: NO_PAGER_ENV,
209
221
  artifactPath,
210
222
  artifactId,
211
223
  onChunk: chunk => {
@@ -238,9 +250,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
238
250
  // Track output for streaming updates (tail only)
239
251
  const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES);
240
252
 
241
- // Set up artifacts environment and allocation
242
- const artifactsDir = this.session.getArtifactsDir?.();
243
- const extraEnv = artifactsDir ? { ARTIFACTS: artifactsDir } : undefined;
253
+ // Allocate artifact for truncated output storage
244
254
  const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("bash")) ?? {};
245
255
 
246
256
  const usePty = pty && $env.PI_NO_PTY !== "1" && ctx?.hasUI === true && ctx.ui !== undefined;
@@ -250,7 +260,6 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
250
260
  cwd: commandCwd,
251
261
  timeoutMs,
252
262
  signal,
253
- env: extraEnv,
254
263
  artifactPath,
255
264
  artifactId,
256
265
  })
@@ -259,7 +268,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
259
268
  sessionKey: this.session.getSessionId?.() ?? undefined,
260
269
  timeout: timeoutMs,
261
270
  signal,
262
- env: extraEnv,
271
+ env: NO_PAGER_ENV,
263
272
  artifactPath,
264
273
  artifactId,
265
274
  onChunk: chunk => {
@@ -8,13 +8,36 @@ import type { ToolSession } from ".";
8
8
  import { resolvePlanPath } from "./plan-mode-guard";
9
9
  import { ToolError } from "./tool-errors";
10
10
 
11
- const exitPlanModeSchema = Type.Object({});
11
+ const exitPlanModeSchema = Type.Object({
12
+ title: Type.String({ description: "Final plan title, e.g. WP_MIGRATION_PLAN" }),
13
+ });
12
14
 
13
15
  type ExitPlanModeParams = Static<typeof exitPlanModeSchema>;
14
16
 
17
+ function normalizePlanTitle(title: string): { title: string; fileName: string } {
18
+ const trimmed = title.trim();
19
+ if (!trimmed) {
20
+ throw new ToolError("Title is required and must not be empty.");
21
+ }
22
+
23
+ if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("..")) {
24
+ throw new ToolError("Title must not contain path separators or '..'.");
25
+ }
26
+
27
+ const withExtension = trimmed.toLowerCase().endsWith(".md") ? trimmed : `${trimmed}.md`;
28
+ if (!/^[A-Za-z0-9_-]+\.md$/.test(withExtension)) {
29
+ throw new ToolError("Title may only contain letters, numbers, underscores, or hyphens.");
30
+ }
31
+
32
+ const normalizedTitle = withExtension.slice(0, -3);
33
+ return { title: normalizedTitle, fileName: withExtension };
34
+ }
35
+
15
36
  export interface ExitPlanModeDetails {
16
37
  planFilePath: string;
17
38
  planExists: boolean;
39
+ title: string;
40
+ finalPlanFilePath: string;
18
41
  }
19
42
 
20
43
  export class ExitPlanModeTool implements AgentTool<typeof exitPlanModeSchema, ExitPlanModeDetails> {
@@ -29,7 +52,7 @@ export class ExitPlanModeTool implements AgentTool<typeof exitPlanModeSchema, Ex
29
52
 
30
53
  async execute(
31
54
  _toolCallId: string,
32
- _params: ExitPlanModeParams,
55
+ params: ExitPlanModeParams,
33
56
  _signal?: AbortSignal,
34
57
  _onUpdate?: AgentToolUpdateCallback<ExitPlanModeDetails>,
35
58
  _context?: AgentToolContext,
@@ -39,7 +62,10 @@ export class ExitPlanModeTool implements AgentTool<typeof exitPlanModeSchema, Ex
39
62
  throw new ToolError("Plan mode is not active.");
40
63
  }
41
64
 
65
+ const normalized = normalizePlanTitle(params.title);
66
+ const finalPlanFilePath = `local://${normalized.fileName}`;
42
67
  const resolvedPlanPath = resolvePlanPath(this.session, state.planFilePath);
68
+ resolvePlanPath(this.session, finalPlanFilePath);
43
69
  let planExists = false;
44
70
  try {
45
71
  const stat = await fs.stat(resolvedPlanPath);
@@ -55,6 +81,8 @@ export class ExitPlanModeTool implements AgentTool<typeof exitPlanModeSchema, Ex
55
81
  details: {
56
82
  planFilePath: state.planFilePath,
57
83
  planExists,
84
+ title: normalized.title,
85
+ finalPlanFilePath,
58
86
  },
59
87
  };
60
88
  }