@oh-my-pi/pi-coding-agent 12.19.2 → 13.0.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.
Files changed (106) hide show
  1. package/CHANGELOG.md +53 -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/config/settings-schema.ts +0 -11
  14. package/src/discovery/helpers.ts +13 -1
  15. package/src/internal-urls/docs-index.generated.ts +2 -2
  16. package/src/internal-urls/index.ts +8 -3
  17. package/src/internal-urls/local-protocol.ts +223 -0
  18. package/src/internal-urls/{docs-protocol.ts → pi-protocol.ts} +12 -12
  19. package/src/internal-urls/router.ts +1 -1
  20. package/src/internal-urls/types.ts +1 -1
  21. package/src/ipy/executor.ts +4 -32
  22. package/src/main.ts +0 -1
  23. package/src/memories/index.ts +1 -1
  24. package/src/modes/components/settings-defs.ts +0 -5
  25. package/src/modes/controllers/event-controller.ts +4 -4
  26. package/src/modes/interactive-mode.ts +84 -64
  27. package/src/modes/types.ts +11 -3
  28. package/src/modes/utils/ui-helpers.ts +5 -3
  29. package/src/patch/hashline.ts +42 -42
  30. package/src/patch/index.ts +24 -21
  31. package/src/patch/shared.ts +21 -43
  32. package/src/plan-mode/approved-plan.ts +55 -0
  33. package/src/prompts/agents/designer.md +6 -6
  34. package/src/prompts/agents/explore.md +4 -4
  35. package/src/prompts/agents/frontmatter.md +1 -0
  36. package/src/prompts/agents/init.md +10 -10
  37. package/src/prompts/agents/plan.md +6 -6
  38. package/src/prompts/agents/reviewer.md +4 -3
  39. package/src/prompts/agents/task.md +10 -10
  40. package/src/prompts/compaction/branch-summary.md +3 -3
  41. package/src/prompts/compaction/compaction-short-summary.md +7 -7
  42. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  43. package/src/prompts/compaction/compaction-summary.md +5 -5
  44. package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
  45. package/src/prompts/compaction/compaction-update-summary.md +11 -11
  46. package/src/prompts/memories/consolidation.md +5 -5
  47. package/src/prompts/memories/read-path.md +11 -0
  48. package/src/prompts/memories/stage_one_input.md +1 -1
  49. package/src/prompts/memories/stage_one_system.md +5 -5
  50. package/src/prompts/review-request.md +4 -4
  51. package/src/prompts/system/agent-creation-architect.md +17 -17
  52. package/src/prompts/system/agent-creation-user.md +2 -2
  53. package/src/prompts/system/custom-system-prompt.md +6 -6
  54. package/src/prompts/system/plan-mode-active.md +20 -20
  55. package/src/prompts/system/plan-mode-approved.md +9 -7
  56. package/src/prompts/system/plan-mode-reference.md +2 -2
  57. package/src/prompts/system/plan-mode-subagent.md +8 -8
  58. package/src/prompts/system/subagent-submit-reminder.md +5 -5
  59. package/src/prompts/system/subagent-system-prompt.md +9 -9
  60. package/src/prompts/system/subagent-user-prompt.md +3 -5
  61. package/src/prompts/system/summarization-system.md +1 -1
  62. package/src/prompts/system/system-prompt.md +109 -84
  63. package/src/prompts/system/title-system.md +2 -2
  64. package/src/prompts/system/ttsr-interrupt.md +2 -2
  65. package/src/prompts/system/web-search.md +16 -16
  66. package/src/prompts/tools/ask.md +6 -6
  67. package/src/prompts/tools/bash.md +9 -9
  68. package/src/prompts/tools/browser.md +5 -5
  69. package/src/prompts/tools/cancel-job.md +2 -2
  70. package/src/prompts/tools/exit-plan-mode.md +13 -10
  71. package/src/prompts/tools/find.md +2 -2
  72. package/src/prompts/tools/gemini-image.md +7 -7
  73. package/src/prompts/tools/grep.md +4 -3
  74. package/src/prompts/tools/hashline.md +37 -39
  75. package/src/prompts/tools/patch.md +5 -5
  76. package/src/prompts/tools/poll-jobs.md +1 -1
  77. package/src/prompts/tools/python.md +8 -10
  78. package/src/prompts/tools/read.md +2 -12
  79. package/src/prompts/tools/replace.md +6 -6
  80. package/src/prompts/tools/ssh.md +2 -7
  81. package/src/prompts/tools/task.md +34 -23
  82. package/src/prompts/tools/todo-write.md +65 -49
  83. package/src/prompts/tools/web-search.md +2 -2
  84. package/src/prompts/tools/write.md +4 -3
  85. package/src/sdk.ts +11 -9
  86. package/src/session/agent-session.ts +92 -51
  87. package/src/session/artifacts.ts +1 -1
  88. package/src/session/messages.ts +1 -0
  89. package/src/task/agents.ts +1 -0
  90. package/src/task/index.ts +2 -1
  91. package/src/task/render.ts +2 -2
  92. package/src/task/types.ts +1 -0
  93. package/src/tools/bash-interactive.ts +1 -1
  94. package/src/tools/bash-skill-urls.ts +3 -2
  95. package/src/tools/bash.ts +38 -19
  96. package/src/tools/exit-plan-mode.ts +30 -2
  97. package/src/tools/grep.ts +131 -75
  98. package/src/tools/index.ts +13 -3
  99. package/src/tools/path-utils.ts +2 -1
  100. package/src/tools/plan-mode-guard.ts +8 -8
  101. package/src/tools/python.ts +0 -2
  102. package/src/tools/read.ts +2 -2
  103. package/src/tools/todo-write.ts +276 -146
  104. package/src/internal-urls/plan-protocol.ts +0 -95
  105. package/src/modes/components/todo-display.ts +0 -114
  106. package/src/prompts/memories/read_path.md +0 -11
package/src/sdk.ts CHANGED
@@ -46,11 +46,11 @@ import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal }
46
46
  import {
47
47
  AgentProtocolHandler,
48
48
  ArtifactProtocolHandler,
49
- DocsProtocolHandler,
50
49
  InternalUrlRouter,
51
50
  JobsProtocolHandler,
51
+ LocalProtocolHandler,
52
52
  MemoryProtocolHandler,
53
- PlanProtocolHandler,
53
+ PiProtocolHandler,
54
54
  RuleProtocolHandler,
55
55
  SkillProtocolHandler,
56
56
  } from "./internal-urls";
@@ -799,6 +799,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
799
799
  },
800
800
  getPlanModeState: () => session.getPlanModeState(),
801
801
  getCompactContext: () => session.formatCompactContext(),
802
+ getTodoPhases: () => session.getTodoPhases(),
803
+ setTodoPhases: phases => session.setTodoPhases(phases),
802
804
  allocateOutputArtifact: async toolType => {
803
805
  try {
804
806
  return await sessionManager.allocateArtifactPath(toolType);
@@ -812,20 +814,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
812
814
  asyncJobManager,
813
815
  };
814
816
 
815
- // Initialize internal URL router for internal protocols (agent://, artifact://, plan://, memory://, skill://, rule://)
817
+ // Initialize internal URL router for internal protocols (agent://, artifact://, memory://, skill://, rule://, local://)
816
818
  const internalRouter = new InternalUrlRouter();
817
819
  const getArtifactsDir = () => sessionManager.getArtifactsDir();
818
820
  internalRouter.register(new AgentProtocolHandler({ getArtifactsDir }));
819
821
  internalRouter.register(new ArtifactProtocolHandler({ getArtifactsDir }));
820
822
  internalRouter.register(
821
- new PlanProtocolHandler({
822
- getPlansDirectory: () => settings.getPlansDirectory(),
823
- cwd,
823
+ new MemoryProtocolHandler({
824
+ getMemoryRoot: () => getMemoryRoot(agentDir, settings.getCwd()),
824
825
  }),
825
826
  );
826
827
  internalRouter.register(
827
- new MemoryProtocolHandler({
828
- getMemoryRoot: () => getMemoryRoot(agentDir, settings.getCwd()),
828
+ new LocalProtocolHandler({
829
+ getArtifactsDir,
830
+ getSessionId: () => sessionManager.getSessionId(),
829
831
  }),
830
832
  );
831
833
  internalRouter.register(
@@ -838,7 +840,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
838
840
  getRules: () => rulebookRules,
839
841
  }),
840
842
  );
841
- internalRouter.register(new DocsProtocolHandler());
843
+ internalRouter.register(new PiProtocolHandler());
842
844
  internalRouter.register(new JobsProtocolHandler({ getAsyncJobManager: () => asyncJobManager }));
843
845
  toolSession.internalRouter = internalRouter;
844
846
  toolSession.getArtifactsDir = getArtifactsDir;
@@ -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";
@@ -34,6 +34,11 @@ const bashSchemaBase = Type.Object({
34
34
  cwd: Type.Optional(Type.String({ description: "Working directory (default: cwd)" })),
35
35
  head: Type.Optional(Type.Number({ description: "Return only first N lines of output" })),
36
36
  tail: Type.Optional(Type.Number({ description: "Return only last N lines of output" })),
37
+ pty: Type.Optional(
38
+ Type.Boolean({
39
+ description: "Run in PTY mode when command needs a real terminal (e.g. sudo/ssh/top/less); default: false",
40
+ }),
41
+ ),
37
42
  });
38
43
 
39
44
  const bashSchemaWithAsync = Type.Object({
@@ -54,6 +59,7 @@ export interface BashToolInput {
54
59
  head?: number;
55
60
  tail?: number;
56
61
  async?: boolean;
62
+ pty?: boolean;
57
63
  }
58
64
 
59
65
  export interface BashToolDetails {
@@ -123,13 +129,29 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
123
129
 
124
130
  async execute(
125
131
  _toolCallId: string,
126
- { command: rawCommand, timeout: rawTimeout = 300, cwd, head, tail, async: asyncRequested = false }: BashToolInput,
132
+ {
133
+ command: rawCommand,
134
+ timeout: rawTimeout = 300,
135
+ cwd,
136
+ head,
137
+ tail,
138
+ async: asyncRequested = false,
139
+ pty = false,
140
+ }: BashToolInput,
127
141
  signal?: AbortSignal,
128
142
  onUpdate?: AgentToolUpdateCallback<BashToolDetails>,
129
143
  ctx?: AgentToolContext,
130
144
  ): Promise<AgentToolResult<BashToolDetails>> {
131
145
  let command = rawCommand;
132
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
+ }
133
155
  if (asyncRequested && !this.#asyncEnabled) {
134
156
  throw new ToolError("Async bash execution is disabled. Enable async.enabled to use async mode.");
135
157
  }
@@ -147,10 +169,16 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
147
169
  }
148
170
  }
149
171
 
150
- command = await expandInternalUrls(command, {
172
+ const internalUrlOptions: InternalUrlExpansionOptions = {
151
173
  skills: this.session.skills ?? [],
152
174
  internalRouter: this.session.internalRouter,
153
- });
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
+ }
154
182
 
155
183
  const commandCwd = cwd ? resolveToCwd(cwd, this.session.cwd) : this.session.cwd;
156
184
  let cwdStat: fs.Stats;
@@ -181,17 +209,15 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
181
209
  "bash",
182
210
  label,
183
211
  async ({ jobId, signal: runSignal, reportProgress }) => {
184
- const artifactsDir = this.session.getArtifactsDir?.();
185
- const extraEnv = artifactsDir ? { ARTIFACTS: artifactsDir } : undefined;
186
212
  const { path: artifactPath, id: artifactId } =
187
213
  (await this.session.allocateOutputArtifact?.("bash")) ?? {};
188
214
  try {
189
215
  const result = await executeBash(command, {
190
216
  cwd: commandCwd,
191
- sessionKey: this.session.getSessionId?.() ?? undefined,
217
+ sessionKey: `${this.session.getSessionId?.() ?? ""}:async:${jobId}`,
192
218
  timeout: timeoutMs,
193
219
  signal: runSignal,
194
- env: extraEnv,
220
+ env: NO_PAGER_ENV,
195
221
  artifactPath,
196
222
  artifactId,
197
223
  onChunk: chunk => {
@@ -224,23 +250,16 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
224
250
  // Track output for streaming updates (tail only)
225
251
  const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES);
226
252
 
227
- // Set up artifacts environment and allocation
228
- const artifactsDir = this.session.getArtifactsDir?.();
229
- const extraEnv = artifactsDir ? { ARTIFACTS: artifactsDir } : undefined;
253
+ // Allocate artifact for truncated output storage
230
254
  const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("bash")) ?? {};
231
255
 
232
- const usePty =
233
- this.session.settings.get("bash.virtualTerminal") === "on" &&
234
- $env.PI_NO_PTY !== "1" &&
235
- ctx?.hasUI === true &&
236
- ctx.ui !== undefined;
256
+ const usePty = pty && $env.PI_NO_PTY !== "1" && ctx?.hasUI === true && ctx.ui !== undefined;
237
257
  const result: BashResult | BashInteractiveResult = usePty
238
258
  ? await runInteractiveBashPty(ctx.ui!, {
239
259
  command,
240
260
  cwd: commandCwd,
241
261
  timeoutMs,
242
262
  signal,
243
- env: extraEnv,
244
263
  artifactPath,
245
264
  artifactId,
246
265
  })
@@ -249,7 +268,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
249
268
  sessionKey: this.session.getSessionId?.() ?? undefined,
250
269
  timeout: timeoutMs,
251
270
  signal,
252
- env: extraEnv,
271
+ env: NO_PAGER_ENV,
253
272
  artifactPath,
254
273
  artifactId,
255
274
  onChunk: chunk => {