@oh-my-pi/pi-coding-agent 13.2.1 → 13.3.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 (35) hide show
  1. package/CHANGELOG.md +29 -2
  2. package/package.json +7 -7
  3. package/scripts/generate-docs-index.ts +2 -2
  4. package/src/cli/args.ts +2 -1
  5. package/src/config/settings-schema.ts +36 -4
  6. package/src/config/settings.ts +10 -0
  7. package/src/discovery/claude.ts +24 -6
  8. package/src/ipy/runtime.ts +1 -0
  9. package/src/mcp/config.ts +1 -1
  10. package/src/modes/components/settings-defs.ts +17 -1
  11. package/src/modes/components/status-line.ts +7 -5
  12. package/src/modes/controllers/mcp-command-controller.ts +4 -3
  13. package/src/modes/controllers/selector-controller.ts +21 -0
  14. package/src/modes/interactive-mode.ts +9 -0
  15. package/src/modes/oauth-manual-input.ts +42 -0
  16. package/src/modes/types.ts +2 -0
  17. package/src/patch/hashline.ts +19 -1
  18. package/src/prompts/system/commit-message-system.md +2 -0
  19. package/src/prompts/system/subagent-submit-reminder.md +3 -3
  20. package/src/prompts/system/subagent-system-prompt.md +4 -4
  21. package/src/prompts/system/system-prompt.md +13 -0
  22. package/src/prompts/tools/hashline.md +45 -1
  23. package/src/prompts/tools/task-summary.md +4 -4
  24. package/src/prompts/tools/task.md +1 -1
  25. package/src/sdk.ts +3 -0
  26. package/src/slash-commands/builtin-registry.ts +26 -1
  27. package/src/system-prompt.ts +4 -0
  28. package/src/task/index.ts +211 -70
  29. package/src/task/render.ts +24 -8
  30. package/src/task/types.ts +6 -1
  31. package/src/task/worktree.ts +394 -31
  32. package/src/tools/submit-result.ts +22 -23
  33. package/src/utils/commit-message-generator.ts +132 -0
  34. package/src/web/search/providers/exa.ts +41 -4
  35. package/src/web/search/providers/perplexity.ts +20 -8
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.3.0] - 2026-02-26
6
+
7
+ ### Breaking Changes
8
+
9
+ - Renamed `task.isolation.enabled` (boolean) setting to `task.isolation.mode` (enum: `none`, `worktree`, `fuse-overlay`). Existing `true`/`false` values are auto-migrated to `worktree`/`none`.
10
+
11
+ ### Added
12
+
13
+ - Added `PERPLEXITY_COOKIES` env var for Perplexity web search via session cookies extracted from desktop app
14
+ - Added `fuse-overlay` isolation mode for subagents using `fuse-overlayfs` (copy-on-write overlay, no baseline patch apply needed)
15
+ - Added `task.isolation.merge` setting (`patch` or `branch`) to control how isolated task changes are integrated back. `branch` mode commits each task to a temp branch and cherry-picks for clean commit history
16
+ - Added `task.isolation.commits` setting (`generic` or `ai`) for commit messages on isolated task branches and nested repos. `ai` mode uses a smol model to generate conventional commit messages from diffs
17
+ - Nested non-submodule git repos are now discovered and handled during task isolation (changes captured and applied independently from parent repo)
18
+ - Added `task.eager` setting to encourage the agent to delegate work to subagents by default
19
+ - Added manual OAuth login flow that lets users paste redirect URLs with /login for callback-server providers and prevents overlapping logins
20
+
21
+ ### Fixed
22
+
23
+ - Fixed nested repo changes being lost when tasks commit inside the isolation (baseline state is now committed before task runs, so delta correctly excludes it)
24
+ - Fixed nested repo patches conflicting when multiple tasks contribute to the same repo (baseline untracked files no longer leak into patches)
25
+ - Nested repo changes are now committed after patch application (previously left as untracked files)
26
+ - Failed tasks no longer create stale branches or capture garbage patches (gated on exit code)
27
+ - Merge failures (e.g. conflicting patches) are now non-fatal — agent output is preserved with `merge failed` status instead of `failed`
28
+ - Stale branches are cleaned up when `commitToBranch` fails
29
+ - Commit message generator filters lock files from diffs before AI summarization
30
+
5
31
  ## [13.2.1] - 2026-02-24
6
32
 
7
33
  ### Fixed
@@ -11,7 +37,6 @@
11
37
  ### Changed
12
38
 
13
39
  - Extracted non-interactive environment config from `bash-interactive.ts` into shared `non-interactive-env.ts` module, applied consistently to all bash execution paths
14
-
15
40
  ## [13.2.0] - 2026-02-23
16
41
  ### Breaking Changes
17
42
 
@@ -34,12 +59,14 @@
34
59
  - Removed unused SSH resource cleanup functions `closeAllConnections` and `unmountAll` from session imports
35
60
 
36
61
  ## [13.1.2] - 2026-02-23
37
- ### Breaking Changes
38
62
 
63
+ ### Breaking Changes
39
64
  - Removed `timeout` parameter from await tool—tool now waits indefinitely until jobs complete or the call is aborted
40
65
  - Renamed `job_ids` parameter to `jobs` in await tool schema
41
66
  - Removed `timedOut` field from await tool result details
42
67
 
68
+ ### Changed
69
+ - Resolved docs index generation paths using path.resolve relative to the script directory
43
70
  ## [13.1.1] - 2026-02-23
44
71
 
45
72
  ### Fixed
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "13.2.1",
4
+ "version": "13.3.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@mozilla/readability": "^0.6",
44
- "@oh-my-pi/omp-stats": "13.2.1",
45
- "@oh-my-pi/pi-agent-core": "13.2.1",
46
- "@oh-my-pi/pi-ai": "13.2.1",
47
- "@oh-my-pi/pi-natives": "13.2.1",
48
- "@oh-my-pi/pi-tui": "13.2.1",
49
- "@oh-my-pi/pi-utils": "13.2.1",
44
+ "@oh-my-pi/omp-stats": "13.3.0",
45
+ "@oh-my-pi/pi-agent-core": "13.3.0",
46
+ "@oh-my-pi/pi-ai": "13.3.0",
47
+ "@oh-my-pi/pi-natives": "13.3.0",
48
+ "@oh-my-pi/pi-tui": "13.3.0",
49
+ "@oh-my-pi/pi-utils": "13.3.0",
50
50
  "@sinclair/typebox": "^0.34",
51
51
  "@xterm/headless": "^6.0",
52
52
  "ajv": "^8.18",
@@ -3,8 +3,8 @@
3
3
  import { Glob } from "bun";
4
4
  import * as path from "node:path";
5
5
 
6
- const docsDir = new URL("../../../docs/", import.meta.url).pathname;
7
- const outputPath = new URL("../src/internal-urls/docs-index.generated.ts", import.meta.url).pathname;
6
+ const docsDir = path.resolve(import.meta.dir, "../../../docs");
7
+ const outputPath = path.resolve(import.meta.dir, "../src/internal-urls/docs-index.generated.ts");
8
8
 
9
9
  const glob = new Glob("**/*.md");
10
10
  const entries: string[] = [];
package/src/cli/args.ts CHANGED
@@ -216,7 +216,8 @@ export function getExtraHelpText(): string {
216
216
  ${chalk.dim("# Search & Tools")}
217
217
  EXA_API_KEY - Exa web search
218
218
  BRAVE_API_KEY - Brave web search
219
- PERPLEXITY_API_KEY - Perplexity web search
219
+ PERPLEXITY_API_KEY - Perplexity web search (API)
220
+ PERPLEXITY_COOKIES - Perplexity web search (session cookie)
220
221
  ANTHROPIC_SEARCH_API_KEY - Anthropic search provider
221
222
 
222
223
  ${chalk.dim("# Configuration")}
@@ -544,16 +544,48 @@ export const SETTINGS_SCHEMA = {
544
544
  // ─────────────────────────────────────────────────────────────────────────
545
545
  // Task tool settings
546
546
  // ─────────────────────────────────────────────────────────────────────────
547
- "task.isolation.enabled": {
548
- type: "boolean",
549
- default: false,
547
+ "task.isolation.mode": {
548
+ type: "enum",
549
+ values: ["none", "worktree", "fuse-overlay"] as const,
550
+ default: "none",
550
551
  ui: {
551
552
  tab: "tools",
552
553
  label: "Task isolation",
553
- description: "Run subagents in isolated git worktrees",
554
+ description: "Isolation mode for subagents (none, git worktree, or fuse-overlay)",
554
555
  submenu: true,
555
556
  },
556
557
  },
558
+ "task.isolation.merge": {
559
+ type: "enum",
560
+ values: ["patch", "branch"] as const,
561
+ default: "patch",
562
+ ui: {
563
+ tab: "tools",
564
+ label: "Task isolation merge",
565
+ description: "How isolated task changes are integrated (patch apply or branch merge)",
566
+ submenu: true,
567
+ },
568
+ },
569
+ "task.isolation.commits": {
570
+ type: "enum",
571
+ values: ["generic", "ai"] as const,
572
+ default: "generic",
573
+ ui: {
574
+ tab: "tools",
575
+ label: "Task isolation commits",
576
+ description: "Commit message style for nested repo changes (generic or AI-generated)",
577
+ submenu: true,
578
+ },
579
+ },
580
+ "task.eager": {
581
+ type: "boolean",
582
+ default: false,
583
+ ui: {
584
+ tab: "tools",
585
+ label: "Eager task delegation",
586
+ description: "Encourage the agent to delegate work to subagents unless changes are trivial",
587
+ },
588
+ },
557
589
  "task.maxConcurrency": {
558
590
  type: "number",
559
591
  default: 32,
@@ -553,6 +553,16 @@ export class Settings {
553
553
  }
554
554
  }
555
555
 
556
+ // task.isolation.enabled (boolean) -> task.isolation.mode (enum)
557
+ const taskObj = raw.task as Record<string, unknown> | undefined;
558
+ const isolationObj = taskObj?.isolation as Record<string, unknown> | undefined;
559
+ if (isolationObj && "enabled" in isolationObj) {
560
+ if (typeof isolationObj.enabled === "boolean") {
561
+ isolationObj.mode = isolationObj.enabled ? "worktree" : "none";
562
+ }
563
+ delete isolationObj.enabled;
564
+ }
565
+
556
566
  return raw;
557
567
  }
558
568
 
@@ -5,7 +5,7 @@
5
5
  * Priority: 80 (tool-specific, below builtin but above shared standards)
6
6
  */
7
7
  import * as path from "node:path";
8
- import { tryParseJson } from "@oh-my-pi/pi-utils";
8
+ import { hasFsCode, tryParseJson } from "@oh-my-pi/pi-utils";
9
9
  import { registerProvider } from "../capability";
10
10
  import { type ContextFile, contextFileCapability } from "../capability/context-file";
11
11
  import { type ExtensionModule, extensionModuleCapability } from "../capability/extension-module";
@@ -47,6 +47,10 @@ function getProjectClaude(ctx: LoadContext): string {
47
47
  return path.join(ctx.cwd, CONFIG_DIR);
48
48
  }
49
49
 
50
+ function isMissingDirectoryError(error: unknown): boolean {
51
+ return hasFsCode(error, "ENOENT") || hasFsCode(error, "ENOTDIR");
52
+ }
53
+
50
54
  // =============================================================================
51
55
  // MCP Servers
52
56
  // =============================================================================
@@ -162,15 +166,29 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
162
166
  const userSkillsDir = path.join(getUserClaude(ctx), "skills");
163
167
  const projectSkillsDir = path.join(getProjectClaude(ctx), "skills");
164
168
 
165
- const results = await Promise.all([
169
+ const [userResult, projectResult] = await Promise.allSettled([
166
170
  scanSkillsFromDir(ctx, { dir: userSkillsDir, providerId: PROVIDER_ID, level: "user" }),
167
171
  scanSkillsFromDir(ctx, { dir: projectSkillsDir, providerId: PROVIDER_ID, level: "project" }),
168
172
  ]);
169
173
 
170
- return {
171
- items: results.flatMap(r => r.items),
172
- warnings: results.flatMap(r => r.warnings ?? []),
173
- };
174
+ const items: Skill[] = [];
175
+ const warnings: string[] = [];
176
+
177
+ if (userResult.status === "fulfilled") {
178
+ items.push(...userResult.value.items);
179
+ warnings.push(...(userResult.value.warnings ?? []));
180
+ } else if (!isMissingDirectoryError(userResult.reason)) {
181
+ warnings.push(`Failed to scan Claude user skills in ${userSkillsDir}: ${String(userResult.reason)}`);
182
+ }
183
+
184
+ if (projectResult.status === "fulfilled") {
185
+ items.push(...projectResult.value.items);
186
+ warnings.push(...(projectResult.value.warnings ?? []));
187
+ } else if (!isMissingDirectoryError(projectResult.reason)) {
188
+ warnings.push(`Failed to scan Claude project skills in ${projectSkillsDir}: ${String(projectResult.reason)}`);
189
+ }
190
+
191
+ return { items, warnings };
174
192
  }
175
193
 
176
194
  // =============================================================================
@@ -72,6 +72,7 @@ const DEFAULT_ENV_DENYLIST = new Set([
72
72
  "GEMINI_API_KEY",
73
73
  "OPENROUTER_API_KEY",
74
74
  "PERPLEXITY_API_KEY",
75
+ "PERPLEXITY_COOKIES",
75
76
  "EXA_API_KEY",
76
77
  "AZURE_OPENAI_API_KEY",
77
78
  "MISTRAL_API_KEY",
package/src/mcp/config.ts CHANGED
@@ -110,7 +110,7 @@ export async function loadAllMCPConfigs(cwd: string, options?: LoadMCPConfigsOpt
110
110
  let sources: Record<string, SourceMeta> = {};
111
111
  for (const server of servers) {
112
112
  const config = convertToLegacyConfig(server);
113
- if (config.enabled === false || (server._source.level !== "user" && disabledServers.has(server.name))) {
113
+ if (config.enabled === false || disabledServers.has(server.name)) {
114
114
  continue;
115
115
  }
116
116
  configs[server.name] = config;
@@ -93,6 +93,22 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
93
93
  { value: "2", label: "Double" },
94
94
  { value: "3", label: "Triple" },
95
95
  ],
96
+ // Task isolation mode
97
+ "task.isolation.mode": [
98
+ { value: "none", label: "None", description: "No isolation" },
99
+ { value: "worktree", label: "Worktree", description: "Git worktree isolation" },
100
+ { value: "fuse-overlay", label: "Fuse Overlay", description: "COW overlay via fuse-overlayfs" },
101
+ ],
102
+ // Task isolation merge strategy
103
+ "task.isolation.merge": [
104
+ { value: "patch", label: "Patch", description: "Combine diffs and git apply" },
105
+ { value: "branch", label: "Branch", description: "Commit per task, merge with --no-ff" },
106
+ ],
107
+ // Task isolation commit messages
108
+ "task.isolation.commits": [
109
+ { value: "generic", label: "Generic", description: "Static commit message" },
110
+ { value: "ai", label: "AI", description: "AI-generated commit message from diff" },
111
+ ],
96
112
  // Todo max reminders
97
113
  "todo.reminders.max": [
98
114
  { value: "1", label: "1 reminder" },
@@ -166,7 +182,7 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
166
182
  { value: "brave", label: "Brave", description: "Requires BRAVE_API_KEY" },
167
183
  { value: "jina", label: "Jina", description: "Requires JINA_API_KEY" },
168
184
  { value: "kimi", label: "Kimi", description: "Requires MOONSHOT_SEARCH_API_KEY or MOONSHOT_API_KEY" },
169
- { value: "perplexity", label: "Perplexity", description: "Requires PERPLEXITY_API_KEY" },
185
+ { value: "perplexity", label: "Perplexity", description: "Requires PERPLEXITY_COOKIES or PERPLEXITY_API_KEY" },
170
186
  { value: "anthropic", label: "Anthropic", description: "Uses Anthropic web search" },
171
187
  { value: "zai", label: "Z.AI", description: "Calls Z.AI webSearchPrime MCP" },
172
188
  { value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" },
@@ -50,6 +50,7 @@ export class StatusLineComponent implements Component {
50
50
  // Git status caching (1s TTL)
51
51
  #cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
52
52
  #gitStatusLastFetch = 0;
53
+ #gitStatusInFlight = false;
53
54
 
54
55
  constructor(private readonly session: AgentSession) {
55
56
  this.#settings = {
@@ -153,11 +154,12 @@ export class StatusLineComponent implements Component {
153
154
  }
154
155
 
155
156
  #getGitStatus(): { staged: number; unstaged: number; untracked: number } | null {
156
- const now = Date.now();
157
- if (now - this.#gitStatusLastFetch < 1000) {
157
+ if (this.#gitStatusInFlight || Date.now() - this.#gitStatusLastFetch < 1000) {
158
158
  return this.#cachedGitStatus;
159
159
  }
160
160
 
161
+ this.#gitStatusInFlight = true;
162
+
161
163
  // Fire async fetch, return cached value
162
164
  (async () => {
163
165
  try {
@@ -165,7 +167,6 @@ export class StatusLineComponent implements Component {
165
167
 
166
168
  if (result.exitCode !== 0) {
167
169
  this.#cachedGitStatus = null;
168
- this.#gitStatusLastFetch = now;
169
170
  return;
170
171
  }
171
172
 
@@ -195,10 +196,11 @@ export class StatusLineComponent implements Component {
195
196
  }
196
197
 
197
198
  this.#cachedGitStatus = { staged, unstaged, untracked };
198
- this.#gitStatusLastFetch = now;
199
199
  } catch {
200
200
  this.#cachedGitStatus = null;
201
- this.#gitStatusLastFetch = now;
201
+ } finally {
202
+ this.#gitStatusLastFetch = Date.now();
203
+ this.#gitStatusInFlight = false;
202
204
  }
203
205
  })();
204
206
 
@@ -739,10 +739,12 @@ export class MCPCommandController {
739
739
 
740
740
  // Collect runtime-discovered servers not in config files
741
741
  const configServerNames = new Set([...userServers, ...projectServers]);
742
+ const disabledServerNames = new Set(await readDisabledServers(userPath));
742
743
  const discoveredServers: { name: string; source: SourceMeta }[] = [];
743
744
  if (this.ctx.mcpManager) {
744
745
  for (const name of this.ctx.mcpManager.getAllServerNames()) {
745
746
  if (configServerNames.has(name)) continue;
747
+ if (disabledServerNames.has(name)) continue;
746
748
  const source = this.ctx.mcpManager.getSource(name);
747
749
  if (source) {
748
750
  discoveredServers.push({ name, source });
@@ -754,7 +756,7 @@ export class MCPCommandController {
754
756
  userServers.length === 0 &&
755
757
  projectServers.length === 0 &&
756
758
  discoveredServers.length === 0 &&
757
- (userConfig.disabledServers ?? []).length === 0
759
+ disabledServerNames.size === 0
758
760
  ) {
759
761
  this.#showMessage(
760
762
  [
@@ -851,8 +853,7 @@ export class MCPCommandController {
851
853
  }
852
854
 
853
855
  // Show servers disabled via /mcp disable (from third-party configs)
854
- const disabledServers = await readDisabledServers(userPath);
855
- const relevantDisabled = disabledServers.filter(n => !configServerNames.has(n));
856
+ const relevantDisabled = [...disabledServerNames].filter(n => !configServerNames.has(n));
856
857
  if (relevantDisabled.length > 0) {
857
858
  lines.push(theme.fg("accent", "Disabled") + theme.fg("muted", " (discovered servers):"));
858
859
  for (const name of relevantDisabled) {
@@ -31,6 +31,16 @@ import { ToolExecutionComponent } from "../components/tool-execution";
31
31
  import { TreeSelectorComponent } from "../components/tree-selector";
32
32
  import { UserMessageSelectorComponent } from "../components/user-message-selector";
33
33
 
34
+ const CALLBACK_SERVER_PROVIDERS = new Set<OAuthProvider>([
35
+ "anthropic",
36
+ "openai-codex",
37
+ "gitlab-duo",
38
+ "google-gemini-cli",
39
+ "google-antigravity",
40
+ ]);
41
+
42
+ const MANUAL_LOGIN_TIP = "Tip: You can complete pairing with /login <redirect URL>.";
43
+
34
44
  export class SelectorController {
35
45
  constructor(private ctx: InteractiveModeContext) {}
36
46
 
@@ -600,6 +610,8 @@ export class SelectorController {
600
610
  done();
601
611
  if (mode === "login") {
602
612
  this.ctx.showStatus(`Logging in to ${providerId}…`);
613
+ const manualInput = this.ctx.oauthManualInput;
614
+ const useManualInput = CALLBACK_SERVER_PROVIDERS.has(providerId as OAuthProvider);
603
615
  try {
604
616
  await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
605
617
  onAuth: (info: { url: string; instructions?: string }) => {
@@ -612,6 +624,10 @@ export class SelectorController {
612
624
  this.ctx.chatContainer.addChild(new Spacer(1));
613
625
  this.ctx.chatContainer.addChild(new Text(theme.fg("warning", info.instructions), 1, 0));
614
626
  }
627
+ if (useManualInput) {
628
+ this.ctx.chatContainer.addChild(new Spacer(1));
629
+ this.ctx.chatContainer.addChild(new Text(theme.fg("dim", MANUAL_LOGIN_TIP), 1, 0));
630
+ }
615
631
  this.ctx.ui.requestRender();
616
632
  this.ctx.openInBrowser(info.url);
617
633
  },
@@ -641,6 +657,7 @@ export class SelectorController {
641
657
  this.ctx.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0));
642
658
  this.ctx.ui.requestRender();
643
659
  },
660
+ onManualCodeInput: useManualInput ? () => manualInput.waitForInput(providerId) : undefined,
644
661
  });
645
662
  // Refresh models to pick up new baseUrl (e.g., github-copilot)
646
663
  await this.ctx.session.modelRegistry.refresh();
@@ -658,6 +675,10 @@ export class SelectorController {
658
675
  this.ctx.ui.requestRender();
659
676
  } catch (error: unknown) {
660
677
  this.ctx.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
678
+ } finally {
679
+ if (useManualInput) {
680
+ manualInput.clear(`Manual OAuth input cleared for ${providerId}`);
681
+ }
661
682
  }
662
683
  } else {
663
684
  try {
@@ -51,6 +51,7 @@ import { InputController } from "./controllers/input-controller";
51
51
  import { MCPCommandController } from "./controllers/mcp-command-controller";
52
52
  import { SelectorController } from "./controllers/selector-controller";
53
53
  import { SSHCommandController } from "./controllers/ssh-command-controller";
54
+ import { OAuthManualInputManager } from "./oauth-manual-input";
54
55
  import { setMermaidRenderCallback } from "./theme/mermaid-cache";
55
56
  import type { Theme } from "./theme/theme";
56
57
  import { getEditorTheme, getMarkdownTheme, onThemeChange, theme } from "./theme/theme";
@@ -133,6 +134,7 @@ export class InteractiveMode implements InteractiveModeContext {
133
134
  lastStatusText: Text | undefined = undefined;
134
135
  fileSlashCommands: Set<string> = new Set();
135
136
  skillCommands: Map<string, string> = new Map();
137
+ oauthManualInput: OAuthManualInputManager = new OAuthManualInputManager();
136
138
 
137
139
  #pendingSlashCommands: SlashCommand[] = [];
138
140
  #cleanupUnsubscribe?: () => void;
@@ -674,6 +676,13 @@ export class InteractiveMode implements InteractiveModeContext {
674
676
  const previousTools = this.#planModePreviousTools ?? this.session.getActiveToolNames();
675
677
  await this.#exitPlanMode({ silent: true, paused: false });
676
678
  await this.handleClearCommand();
679
+ // The new session has a fresh local:// root — persist the approved plan there
680
+ // so `local://<title>.md` resolves correctly in the execution session.
681
+ const newLocalPath = resolveLocalUrlToPath(options.finalPlanFilePath, {
682
+ getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
683
+ getSessionId: () => this.sessionManager.getSessionId(),
684
+ });
685
+ await Bun.write(newLocalPath, planContent);
677
686
  if (previousTools.length > 0) {
678
687
  await this.session.setActiveToolsByName(previousTools);
679
688
  }
@@ -0,0 +1,42 @@
1
+ type PendingInput = {
2
+ providerId: string;
3
+ resolve: (value: string) => void;
4
+ reject: (error: Error) => void;
5
+ };
6
+
7
+ export class OAuthManualInputManager {
8
+ #pending?: PendingInput;
9
+
10
+ waitForInput(providerId: string): Promise<string> {
11
+ if (this.#pending) {
12
+ this.clear("Manual OAuth input superseded by a new login");
13
+ }
14
+
15
+ const { promise, resolve, reject } = Promise.withResolvers<string>();
16
+ this.#pending = { providerId, resolve, reject };
17
+ return promise;
18
+ }
19
+
20
+ submit(input: string): boolean {
21
+ if (!this.#pending) return false;
22
+ const { resolve } = this.#pending;
23
+ this.#pending = undefined;
24
+ resolve(input);
25
+ return true;
26
+ }
27
+
28
+ clear(reason = "Manual OAuth input cleared"): void {
29
+ if (!this.#pending) return;
30
+ const { reject } = this.#pending;
31
+ this.#pending = undefined;
32
+ reject(new Error(reason));
33
+ }
34
+
35
+ hasPending(): boolean {
36
+ return Boolean(this.#pending);
37
+ }
38
+
39
+ get pendingProviderId(): string | undefined {
40
+ return this.#pending?.providerId;
41
+ }
42
+ }
@@ -19,6 +19,7 @@ import type { HookSelectorComponent } from "./components/hook-selector";
19
19
  import type { PythonExecutionComponent } from "./components/python-execution";
20
20
  import type { StatusLineComponent } from "./components/status-line";
21
21
  import type { ToolExecutionHandle } from "./components/tool-execution";
22
+ import type { OAuthManualInputManager } from "./oauth-manual-input";
22
23
  import type { Theme } from "./theme/theme";
23
24
 
24
25
  export type CompactionQueuedMessage = {
@@ -97,6 +98,7 @@ export interface InteractiveModeContext {
97
98
  lastStatusText: Text | undefined;
98
99
  fileSlashCommands: Set<string>;
99
100
  skillCommands: Map<string, string>;
101
+ oauthManualInput: OAuthManualInputManager;
100
102
  todoPhases: TodoPhase[];
101
103
 
102
104
  // Lifecycle
@@ -444,6 +444,7 @@ export function applyHashlineEdits(
444
444
  const originalFileLines = [...fileLines];
445
445
  let firstChangedLine: number | undefined;
446
446
  const noopEdits: Array<{ editIndex: number; loc: string; current: string }> = [];
447
+ const warnings: string[] = [];
447
448
 
448
449
  // Pre-validate: collect all hash mismatches before mutating
449
450
  const mismatches: HashMismatch[] = [];
@@ -580,7 +581,23 @@ export function applyHashlineEdits(
580
581
  trackFirstChanged(edit.pos.line);
581
582
  } else {
582
583
  const count = edit.end.line - edit.pos.line + 1;
583
- const newLines = edit.lines;
584
+ const newLines = [...edit.lines];
585
+ const trailingReplacementLine = newLines[newLines.length - 1];
586
+ const nextSurvivingLine = fileLines[edit.end.line];
587
+ if (
588
+ trailingReplacementLine !== undefined &&
589
+ trailingReplacementLine.trim().length > 0 &&
590
+ nextSurvivingLine !== undefined &&
591
+ trailingReplacementLine.trim() === nextSurvivingLine.trim() &&
592
+ // Safety: only correct when end-line content differs from the duplicate.
593
+ // If end already points to the boundary, matching next line is coincidence.
594
+ fileLines[edit.end.line - 1].trim() !== trailingReplacementLine.trim()
595
+ ) {
596
+ newLines.pop();
597
+ warnings.push(
598
+ `Auto-corrected range replace ${edit.pos.line}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}: removed trailing replacement line "${trailingReplacementLine.trim()}" that duplicated next surviving line`,
599
+ );
600
+ }
584
601
  fileLines.splice(edit.pos.line - 1, count, ...newLines);
585
602
  trackFirstChanged(edit.pos.line);
586
603
  }
@@ -639,6 +656,7 @@ export function applyHashlineEdits(
639
656
  return {
640
657
  lines: fileLines.join("\n"),
641
658
  firstChangedLine,
659
+ ...(warnings.length > 0 ? { warnings } : {}),
642
660
  ...(noopEdits.length > 0 ? { noopEdits } : {}),
643
661
  };
644
662
 
@@ -0,0 +1,2 @@
1
+ Generate a concise git commit message from the provided diff. Use conventional commit format: `type(scope): description` where type is feat/fix/refactor/chore/test/docs and scope is optional. The description **MUST** be lowercase, imperative mood, no trailing period. Keep it under 72 characters.
2
+ You **MUST** output ONLY the commit message, nothing else.
@@ -2,10 +2,10 @@
2
2
  You stopped without calling submit_result. This is reminder {{retryCount}} of {{maxRetries}}.
3
3
 
4
4
  You **MUST** call submit_result as your only action now. Choose one:
5
- - If task is complete: you **MUST** call submit_result with your result data
6
- - If task failed or was interrupted: you **MUST** call submit_result with status="aborted" and describe what happened
5
+ - If task is complete: call submit_result with your result in the `data` field
6
+ - If task failed: call submit_result with an `error` field describing what happened
7
7
 
8
- You **MUST NOT** choose aborted if you can still complete the task through exploration (using available tools or repo context). If you abort, you **MUST** include what you tried and the exact blocker.
8
+ You **MUST NOT** give up if you can still complete the task through exploration (using available tools or repo context). If you submit an error, you **MUST** include what you tried and the exact blocker.
9
9
 
10
10
  You **MUST NOT** output text without a tool call. You **MUST** call submit_result to finish.
11
11
  </system-reminder>
@@ -29,11 +29,11 @@ Your result **MUST** match this TypeScript interface:
29
29
  {{/if}}
30
30
 
31
31
  {{SECTION_SEPERATOR "Giving Up"}}
32
- If you cannot complete the assignment, you **MUST** call `submit_result` exactly once with `status="aborted"` and an error message describing what you tried and the exact blocker.
32
+ If you cannot complete the assignment, you **MUST** call `submit_result` exactly once with an `error` message describing what you tried and the exact blocker.
33
33
 
34
- Aborting is a last resort.
35
- You **MUST NOT** abort due to uncertainty or missing information obtainable via tools or repo context.
36
- You **MUST NOT** abort due to requiring a design, you can derive that yourself, more than capable of that.
34
+ Giving up is a last resort.
35
+ You **MUST NOT** give up due to uncertainty or missing information obtainable via tools or repo context.
36
+ You **MUST NOT** give up due to requiring a design, you can derive that yourself, more than capable of that.
37
37
 
38
38
  Proceed with the best approach using the most reasonable option.
39
39
 
@@ -159,6 +159,19 @@ Semantic questions **MUST** be answered with semantic tools.
159
159
  - What is this thing? → `lsp hover`
160
160
  {{/has}}
161
161
 
162
+ {{#if eagerTasks}}
163
+ <eager-tasks>
164
+ You **SHOULD** delegate work to subagents by default. Working alone is the exception, not the rule.
165
+
166
+ Use the Task tool unless the change is:
167
+ - A single-file edit under ~30 lines
168
+ - A direct answer or explanation with no code changes
169
+ - A command the user asked you to run yourself
170
+
171
+ For everything else — multi-file changes, refactors, new features, test additions, investigations — break the work into tasks and delegate. Err on the side of delegating. You are an orchestrator first, a coder second.
172
+ </eager-tasks>
173
+ {{/if}}
174
+
162
175
  {{#has tools "ssh"}}
163
176
  ### SSH: match commands to host shell
164
177