@oh-my-pi/pi-coding-agent 15.2.4 → 15.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 (50) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/types/config/settings-schema.d.ts +34 -1
  3. package/dist/types/config/settings.d.ts +6 -0
  4. package/dist/types/discovery/helpers.d.ts +1 -0
  5. package/dist/types/goals/runtime.d.ts +4 -0
  6. package/dist/types/modes/components/status-line/types.d.ts +10 -0
  7. package/dist/types/modes/components/status-line.d.ts +10 -0
  8. package/dist/types/modes/interactive-mode.d.ts +3 -1
  9. package/dist/types/modes/types.d.ts +3 -1
  10. package/dist/types/modes/utils/context-usage.d.ts +17 -0
  11. package/dist/types/modes/utils/ui-helpers.d.ts +5 -1
  12. package/dist/types/session/agent-session.d.ts +9 -0
  13. package/dist/types/task/executor.d.ts +3 -1
  14. package/dist/types/task/types.d.ts +35 -0
  15. package/dist/types/tools/bash-command-fixup.d.ts +0 -5
  16. package/dist/types/utils/clipboard.d.ts +3 -1
  17. package/dist/types/utils/image-resize.d.ts +4 -1
  18. package/package.json +7 -7
  19. package/src/config/settings-schema.ts +29 -1
  20. package/src/config/settings.ts +19 -0
  21. package/src/discovery/helpers.ts +5 -1
  22. package/src/extensibility/plugins/legacy-pi-compat.ts +27 -5
  23. package/src/goals/runtime.ts +35 -13
  24. package/src/main.ts +1 -1
  25. package/src/modes/components/model-selector.ts +53 -22
  26. package/src/modes/components/status-line/segments.ts +53 -0
  27. package/src/modes/components/status-line/types.ts +4 -0
  28. package/src/modes/components/status-line.ts +147 -12
  29. package/src/modes/controllers/command-controller.ts +9 -0
  30. package/src/modes/controllers/event-controller.ts +8 -0
  31. package/src/modes/interactive-mode.ts +23 -8
  32. package/src/modes/theme/theme.ts +1 -1
  33. package/src/modes/types.ts +1 -1
  34. package/src/modes/utils/context-usage.ts +25 -2
  35. package/src/modes/utils/ui-helpers.ts +11 -1
  36. package/src/prompts/agents/frontmatter.md +1 -0
  37. package/src/sdk.ts +24 -0
  38. package/src/session/agent-session.ts +58 -0
  39. package/src/session/session-manager.ts +54 -1
  40. package/src/slash-commands/builtin-registry.ts +10 -0
  41. package/src/task/executor.ts +50 -1
  42. package/src/task/index.ts +11 -0
  43. package/src/task/render.ts +26 -2
  44. package/src/task/types.ts +35 -0
  45. package/src/tools/bash-command-fixup.ts +0 -10
  46. package/src/tools/bash.ts +1 -9
  47. package/src/utils/clipboard.ts +68 -3
  48. package/src/utils/image-resize.ts +51 -26
  49. package/dist/types/modes/components/status-line-segment-editor.d.ts +0 -24
  50. package/src/modes/components/status-line-segment-editor.ts +0 -359
package/src/sdk.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  type AgentMessage,
5
5
  type AgentTelemetryConfig,
6
6
  type AgentTool,
7
+ AppendOnlyContextManager,
7
8
  INTENT_FIELD,
8
9
  type ThinkingLevel,
9
10
  } from "@oh-my-pi/pi-agent-core";
@@ -589,6 +590,24 @@ function registerPythonCleanup(): void {
589
590
  postmortem.register("python-cleanup", disposeAllKernelSessions);
590
591
  }
591
592
 
593
+ /**
594
+ * Resolve whether to enable append-only context mode based on the setting and provider.
595
+ *
596
+ * - `"on"` → always enable
597
+ * - `"off"` → never enable
598
+ * - `"auto"` → enable for DeepSeek (prefix-caching provider)
599
+ */
600
+ function resolveAppendOnlyMode(setting: "auto" | "on" | "off" | undefined, provider: string): boolean {
601
+ switch (setting ?? "auto") {
602
+ case "on":
603
+ return true;
604
+ case "off":
605
+ return false;
606
+ default:
607
+ return provider === "deepseek";
608
+ }
609
+ }
610
+
592
611
  function customToolToDefinition(tool: CustomTool): ToolDefinition {
593
612
  const definition: ToolDefinition & { [TOOL_DEFINITION_MARKER]: true } = {
594
613
  name: tool.name,
@@ -1897,6 +1916,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1897
1916
  intentTracing: !!intentField,
1898
1917
  getToolChoice: () => session?.nextToolChoice(),
1899
1918
  telemetry: options.telemetry,
1919
+ appendOnlyContext: model
1920
+ ? resolveAppendOnlyMode(settings.get("provider.appendOnlyContext"), model.provider)
1921
+ ? new AppendOnlyContextManager()
1922
+ : undefined
1923
+ : undefined,
1900
1924
  });
1901
1925
 
1902
1926
  cursorEventEmitter = event => agent.emitExternalEvent(event);
@@ -26,6 +26,7 @@ import {
26
26
  type AgentMessage,
27
27
  type AgentState,
28
28
  type AgentTool,
29
+ AppendOnlyContextManager,
29
30
  resolveTelemetry,
30
31
  ThinkingLevel,
31
32
  } from "@oh-my-pi/pi-agent-core";
@@ -98,6 +99,7 @@ import {
98
99
  } from "../config/model-resolver";
99
100
  import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
100
101
  import type { Settings, SkillsSettings } from "../config/settings";
102
+ import { onAppendOnlyModeChanged } from "../config/settings";
101
103
  import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
102
104
  import { loadCapability } from "../discovery";
103
105
  import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
@@ -1138,6 +1140,8 @@ export class AgentSession {
1138
1140
  // Always subscribe to agent events for internal handling
1139
1141
  // (session persistence, hooks, auto-compaction, retry logic)
1140
1142
  this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
1143
+ // Re-evaluate append-only context mode when the setting changes at runtime.
1144
+ onAppendOnlyModeChanged(_value => this.#syncAppendOnlyContext(this.model));
1141
1145
  }
1142
1146
 
1143
1147
  /** Model registry for API key resolution and model discovery */
@@ -3573,6 +3577,18 @@ export class AgentSession {
3573
3577
  return this.#autoCompactionAbortController !== undefined || this.#compactionAbortController !== undefined;
3574
3578
  }
3575
3579
 
3580
+ /**
3581
+ * Whether idle-flush tasks, auto-continuations, or other short-lived
3582
+ * post-prompt work are pending. True in the brief window after
3583
+ * `session.prompt()` returns but before a scheduled background delivery
3584
+ * (e.g. an async-job result) has finished its own streaming turn.
3585
+ * Loop-mode and similar auto-submit paths should treat this as a block
3586
+ * to avoid racing against the delivery turn.
3587
+ */
3588
+ get hasPostPromptWork(): boolean {
3589
+ return this.#postPromptTasks.size > 0;
3590
+ }
3591
+
3576
3592
  /** All messages including custom types like BashExecutionMessage */
3577
3593
  get messages(): AgentMessage[] {
3578
3594
  return this.agent.state.messages;
@@ -5947,6 +5963,9 @@ export class AgentSession {
5947
5963
  this.#closeProviderSessionsForModelSwitch(currentModel, model);
5948
5964
  }
5949
5965
  this.agent.setModel(model);
5966
+
5967
+ // Re-evaluate append-only context mode — provider or setting may have changed
5968
+ this.#syncAppendOnlyContext(model);
5950
5969
  }
5951
5970
 
5952
5971
  #closeCodexProviderSessionsForHistoryRewrite(): void {
@@ -5955,6 +5974,24 @@ export class AgentSession {
5955
5974
  this.#closeProviderSessionsForModelSwitch(currentModel, currentModel);
5956
5975
  }
5957
5976
 
5977
+ /**
5978
+ * Re-evaluate append-only context mode, creating or destroying the
5979
+ * manager as needed. Called on model switch AND setting change.
5980
+ */
5981
+ #syncAppendOnlyContext(model: Model | null | undefined): void {
5982
+ const setting = this.settings.get("provider.appendOnlyContext") ?? "auto";
5983
+ const enable = setting === "on" || (setting === "auto" && model?.provider === "deepseek");
5984
+ if (enable && !this.agent.appendOnlyContext) {
5985
+ this.agent.setAppendOnlyContext(new AppendOnlyContextManager());
5986
+ } else if (enable && this.agent.appendOnlyContext) {
5987
+ // Already active — invalidate prefix + log so the next turn
5988
+ // rebuilds for the current model's normalization.
5989
+ this.agent.appendOnlyContext.invalidateForModelChange();
5990
+ } else if (!enable && this.agent.appendOnlyContext) {
5991
+ this.agent.setAppendOnlyContext(undefined);
5992
+ }
5993
+ }
5994
+
5958
5995
  #closeProviderSessionsForModelSwitch(currentModel: Model, nextModel: Model): void {
5959
5996
  const providerKeys = new Set<string>();
5960
5997
  if (currentModel.api === "openai-codex-responses" || nextModel.api === "openai-codex-responses") {
@@ -7071,6 +7108,27 @@ export class AgentSession {
7071
7108
  }
7072
7109
  }
7073
7110
 
7111
+ // Fail-fast cap: if the provider asks us to wait longer than
7112
+ // retry.maxDelayMs and we have no fallback credential or model to
7113
+ // switch to, surface the error instead of sleeping. Defends against
7114
+ // 3-hour Anthropic rate-limit windows that would otherwise leave a
7115
+ // subagent (or interactive session) silently hung. The original
7116
+ // assistant error message is preserved in agent state so the caller
7117
+ // can act on it.
7118
+ const maxDelayMs = retrySettings.maxDelayMs;
7119
+ if (maxDelayMs > 0 && delayMs > maxDelayMs && !switchedCredential && !switchedModel) {
7120
+ const attempt = this.#retryAttempt;
7121
+ this.#retryAttempt = 0;
7122
+ await this.#emitSessionEvent({
7123
+ type: "auto_retry_end",
7124
+ success: false,
7125
+ attempt,
7126
+ finalError: `Provider requested ${delayMs}ms wait, exceeds retry.maxDelayMs (${maxDelayMs}ms). Original error: ${errorMessage}`,
7127
+ });
7128
+ this.#resolveRetry();
7129
+ return false;
7130
+ }
7131
+
7074
7132
  await this.#emitSessionEvent({
7075
7133
  type: "auto_retry_start",
7076
7134
  attempt: this.#retryAttempt,
@@ -18,6 +18,7 @@ import {
18
18
  getProjectDir,
19
19
  getSessionsDir,
20
20
  getTerminalSessionsDir,
21
+ hasFsCode,
21
22
  isEnoent,
22
23
  logger,
23
24
  parseJsonlLenient,
@@ -2146,7 +2147,59 @@ export class SessionManager {
2146
2147
  { ignoreError: true },
2147
2148
  );
2148
2149
  }
2150
+ // Windows can reject overwrite-style rename with EPERM even after our own writer is closed.
2151
+ // Move the old session file aside first so a failed retry can roll back to the last good file.
2149
2152
 
2153
+ async #replaceSessionFileAfterEperm(tempPath: string, targetPath: string, renameError: unknown): Promise<void> {
2154
+ const dir = path.resolve(targetPath, "..");
2155
+ const backupPath = path.join(dir, `.${path.basename(targetPath)}.${Snowflake.next()}.bak`);
2156
+ try {
2157
+ await this.storage.rename(targetPath, backupPath);
2158
+ } catch (err) {
2159
+ if (isEnoent(err)) {
2160
+ await this.storage.rename(tempPath, targetPath);
2161
+ return;
2162
+ }
2163
+ throw toError(renameError);
2164
+ }
2165
+
2166
+ try {
2167
+ await this.storage.rename(tempPath, targetPath);
2168
+ } catch (err) {
2169
+ const replaceError = toError(err);
2170
+ try {
2171
+ await this.storage.rename(backupPath, targetPath);
2172
+ } catch (rollbackErr) {
2173
+ const rollbackError = toError(rollbackErr);
2174
+ throw new Error(
2175
+ `Failed to replace session file after EPERM (${replaceError.message}); rollback from ${backupPath} also failed: ${rollbackError.message}`,
2176
+ { cause: replaceError },
2177
+ );
2178
+ }
2179
+ throw replaceError;
2180
+ }
2181
+
2182
+ try {
2183
+ await this.storage.unlink(backupPath);
2184
+ } catch (err) {
2185
+ if (!isEnoent(err)) {
2186
+ logger.warn("Failed to remove session rewrite backup", {
2187
+ sessionFile: targetPath,
2188
+ backupPath,
2189
+ error: toError(err).message,
2190
+ });
2191
+ }
2192
+ }
2193
+ }
2194
+
2195
+ async #replaceSessionFile(tempPath: string, targetPath: string): Promise<void> {
2196
+ try {
2197
+ await this.storage.rename(tempPath, targetPath);
2198
+ } catch (err) {
2199
+ if (!hasFsCode(err, "EPERM")) throw toError(err);
2200
+ await this.#replaceSessionFileAfterEperm(tempPath, targetPath, err);
2201
+ }
2202
+ }
2150
2203
  async #writeEntriesAtomically(entries: FileEntry[]): Promise<void> {
2151
2204
  if (!this.#sessionFile) return;
2152
2205
  const dir = path.resolve(this.#sessionFile, "..");
@@ -2159,7 +2212,7 @@ export class SessionManager {
2159
2212
  await writer.flush();
2160
2213
  await writer.fsync();
2161
2214
  await writer.close();
2162
- await this.storage.rename(tempPath, this.#sessionFile);
2215
+ await this.#replaceSessionFile(tempPath, this.#sessionFile);
2163
2216
  } catch (err) {
2164
2217
  try {
2165
2218
  await writer.close();
@@ -72,7 +72,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
72
72
  inlineHint: "[prompt]",
73
73
  allowArgs: true,
74
74
  handleTui: async (command, runtime) => {
75
+ const hadArgs = !!command.args;
75
76
  await runtime.ctx.handlePlanModeCommand(command.args || undefined);
77
+ if (hadArgs && runtime.ctx.planModeEnabled) {
78
+ // plan was already active — preserve the typed command in input history
79
+ runtime.ctx.editor.addToHistory(command.text);
80
+ }
76
81
  runtime.ctx.editor.setText("");
77
82
  },
78
83
  },
@@ -90,7 +95,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
90
95
  inlineHint: "[objective]",
91
96
  allowArgs: true,
92
97
  handleTui: async (command, runtime) => {
98
+ const hadArgs = !!command.args;
93
99
  await runtime.ctx.handleGoalModeCommand(command.args || undefined);
100
+ if (hadArgs && runtime.ctx.goalModeEnabled) {
101
+ // goal was already active — preserve the typed command in input history
102
+ runtime.ctx.editor.addToHistory(command.text);
103
+ }
94
104
  runtime.ctx.editor.setText("");
95
105
  },
96
106
  },
@@ -17,7 +17,7 @@ import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
17
17
  import type { CustomTool } from "../extensibility/custom-tools/types";
18
18
  import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
19
19
  import { getSessionSlashCommands } from "../extensibility/extensions/get-commands-handler";
20
- import type { Skill } from "../extensibility/skills";
20
+ import { buildSkillPromptMessage, type Skill } from "../extensibility/skills";
21
21
  import type { HindsightSessionState } from "../hindsight/state";
22
22
  import type { LocalProtocolOptions } from "../internal-urls";
23
23
  import { callTool } from "../mcp/client";
@@ -29,6 +29,7 @@ import { createAgentSession, discoverAuthStorage } from "../sdk";
29
29
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
30
30
  import type { ArtifactManager } from "../session/artifacts";
31
31
  import type { AuthStorage } from "../session/auth-storage";
32
+ import { SKILL_PROMPT_MESSAGE_TYPE } from "../session/messages";
32
33
  import { SessionManager } from "../session/session-manager";
33
34
  import { truncateTail } from "../session/streaming-output";
34
35
  import type { ContextFileEntry } from "../tools";
@@ -190,6 +191,8 @@ export interface ExecutorOptions {
190
191
  * transition explicitly.
191
192
  */
192
193
  parentTelemetry?: AgentTelemetryConfig;
194
+ /** Skills to autoload via sendCustomMessage before the first prompt */
195
+ autoloadSkills?: Skill[];
193
196
  }
194
197
 
195
198
  function parseStringifiedJson(value: unknown): unknown {
@@ -1347,6 +1350,30 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1347
1350
 
1348
1351
  const MAX_YIELD_RETRIES = 3;
1349
1352
  unsubscribe = session.subscribe(event => {
1353
+ if (event.type === "auto_retry_start") {
1354
+ progress.retryState = {
1355
+ attempt: event.attempt,
1356
+ maxAttempts: event.maxAttempts,
1357
+ delayMs: event.delayMs,
1358
+ errorMessage: event.errorMessage,
1359
+ startedAtMs: Date.now(),
1360
+ };
1361
+ progress.retryFailure = undefined;
1362
+ scheduleProgress(true);
1363
+ return;
1364
+ }
1365
+ if (event.type === "auto_retry_end") {
1366
+ const attempt = progress.retryState?.attempt ?? event.attempt;
1367
+ progress.retryState = undefined;
1368
+ if (!event.success) {
1369
+ progress.retryFailure = {
1370
+ attempt,
1371
+ errorMessage: event.finalError ?? "Auto-retry failed",
1372
+ };
1373
+ }
1374
+ scheduleProgress(true);
1375
+ return;
1376
+ }
1350
1377
  if (isAgentEvent(event)) {
1351
1378
  try {
1352
1379
  processEvent(event);
@@ -1360,6 +1387,21 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1360
1387
  });
1361
1388
 
1362
1389
  checkAbort();
1390
+ // Autoload skills via sendCustomMessage (same mechanic as /skill:<name>)
1391
+ if (options.autoloadSkills?.length) {
1392
+ for (const skill of options.autoloadSkills) {
1393
+ const { message } = await buildSkillPromptMessage(skill, "");
1394
+ await session.sendCustomMessage(
1395
+ {
1396
+ customType: SKILL_PROMPT_MESSAGE_TYPE,
1397
+ content: message,
1398
+ display: false,
1399
+ details: { name: skill.name, path: skill.filePath },
1400
+ },
1401
+ { triggerTurn: false },
1402
+ );
1403
+ }
1404
+ }
1363
1405
  await awaitAbortable(session.prompt(task, { attribution: "agent" }));
1364
1406
  await awaitAbortable(session.waitForIdle());
1365
1407
 
@@ -1367,6 +1409,12 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1367
1409
 
1368
1410
  let retryCount = 0;
1369
1411
  while (!yieldCalled && retryCount < MAX_YIELD_RETRIES && !abortSignal.aborted) {
1412
+ // Skip reminders when the model returned a terminal error (e.g.
1413
+ // rate-limit cap hit, auth failure). Re-prompting would just
1414
+ // hit the same wall, multiplying the failure noise without
1415
+ // any chance of producing a yield.
1416
+ const lastBeforeReminder = session.getLastAssistantMessage();
1417
+ if (lastBeforeReminder?.stopReason === "error") break;
1370
1418
  try {
1371
1419
  retryCount++;
1372
1420
  const reminder = prompt.render(submitReminderTemplate, {
@@ -1566,6 +1614,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1566
1614
  usage: hasUsage ? accumulatedUsage : undefined,
1567
1615
  outputPath,
1568
1616
  extractedToolData: progress.extractedToolData,
1617
+ retryFailure: progress.retryFailure,
1569
1618
  outputMeta,
1570
1619
  };
1571
1620
  }
package/src/task/index.ts CHANGED
@@ -410,6 +410,8 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
410
410
  progress.contextWindow = singleResult?.contextWindow;
411
411
  progress.cost = singleResult?.usage?.cost.total ?? 0;
412
412
  progress.extractedToolData = singleResult?.extractedToolData;
413
+ progress.retryFailure = singleResult?.retryFailure;
414
+ progress.retryState = undefined;
413
415
  }
414
416
  completedJobs += 1;
415
417
  if (singleResult && ((singleResult.aborted ?? false) || singleResult.exitCode !== 0)) {
@@ -830,6 +832,13 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
830
832
  const tasksWithUniqueIds = tasks.map((t, i) => ({ ...t, id: uniqueIds[i] }));
831
833
 
832
834
  const availableSkills = [...(this.session.skills ?? [])];
835
+ // Resolve autoload skills from agent definition against available skills
836
+ const resolvedAutoloadSkills =
837
+ agent.autoloadSkills?.length && availableSkills.length > 0
838
+ ? agent.autoloadSkills
839
+ .map(name => availableSkills.find(s => s.name === name))
840
+ .filter((s): s is NonNullable<typeof s> => s !== undefined)
841
+ : [];
833
842
  const contextFiles = this.session.contextFiles?.filter(
834
843
  file => path.basename(file.path).toLowerCase() !== "agents.md",
835
844
  );
@@ -894,6 +903,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
894
903
  mcpManager: MCPManager.instance(),
895
904
  contextFiles,
896
905
  skills: availableSkills,
906
+ autoloadSkills: resolvedAutoloadSkills,
897
907
  workspaceTree: this.session.workspaceTree,
898
908
  promptTemplates,
899
909
  localProtocolOptions,
@@ -948,6 +958,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
948
958
  mcpManager: MCPManager.instance(),
949
959
  contextFiles,
950
960
  skills: availableSkills,
961
+ autoloadSkills: resolvedAutoloadSkills,
951
962
  workspaceTree: this.session.workspaceTree,
952
963
  promptTemplates,
953
964
  localProtocolOptions,
@@ -551,8 +551,15 @@ function renderAgentProgress(
551
551
  const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
552
552
  let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)}`;
553
553
 
554
- // Only show badge for non-running states (spinner already indicates running)
555
- if (progress.status === "failed" || progress.status === "aborted") {
554
+ // Show retry-blocked badge so the parent immediately sees that a child
555
+ // is sleeping on a provider 429, not silently progressing. Wins over the
556
+ // generic running spinner because "we're waiting on a quota window" is
557
+ // the operationally meaningful state.
558
+ if (progress.retryState && progress.status === "running") {
559
+ statusLine += ` ${formatBadge("retrying", "warning", theme)}`;
560
+ } else if (progress.retryFailure && (progress.status === "failed" || progress.status === "aborted")) {
561
+ statusLine += ` ${formatBadge("rate-limited", "error", theme)}`;
562
+ } else if (progress.status === "failed" || progress.status === "aborted") {
556
563
  const statusLabel = progress.status === "failed" ? "failed" : "aborted";
557
564
  statusLine += ` ${formatBadge(statusLabel, iconColor, theme)}`;
558
565
  }
@@ -598,6 +605,23 @@ function renderAgentProgress(
598
605
  }
599
606
  }
600
607
 
608
+ // Retry detail line: surface why the subagent is paused and roughly how
609
+ // long until the next attempt. Without this, the parent UI would just
610
+ // keep spinning while a child sleeps on a 3-hour provider rate-limit.
611
+ if (progress.retryState && progress.status === "running") {
612
+ const remainingMs = Math.max(0, progress.retryState.startedAtMs + progress.retryState.delayMs - Date.now());
613
+ const waitLabel = remainingMs > 0 ? `in ${formatDuration(remainingMs)}` : "now";
614
+ const summary =
615
+ `retrying ${progress.retryState.attempt}/${progress.retryState.maxAttempts} ${waitLabel}: ` +
616
+ truncateToWidth(replaceTabs(progress.retryState.errorMessage), 60);
617
+ lines.push(`${continuePrefix}${theme.tree.hook} ${theme.fg("warning", summary)}`);
618
+ } else if (progress.retryFailure && progress.status !== "running") {
619
+ const summary = `auto-retry gave up after ${progress.retryFailure.attempt} attempt${
620
+ progress.retryFailure.attempt === 1 ? "" : "s"
621
+ }: ${truncateToWidth(replaceTabs(progress.retryFailure.errorMessage), 80)}`;
622
+ lines.push(`${continuePrefix}${theme.tree.hook} ${theme.fg("error", summary)}`);
623
+ }
624
+
601
625
  // Render extracted tool data inline (e.g., review findings)
602
626
  if (progress.extractedToolData) {
603
627
  // For completed tasks, check for review verdict from yield tool
package/src/task/types.ts CHANGED
@@ -173,6 +173,7 @@ export interface AgentDefinition {
173
173
  thinkingLevel?: ThinkingLevel;
174
174
  output?: unknown;
175
175
  blocking?: boolean;
176
+ autoloadSkills?: string[];
176
177
  source: AgentSource;
177
178
  filePath?: string;
178
179
  }
@@ -211,6 +212,30 @@ export interface AgentProgress {
211
212
  modelOverride?: string | string[];
212
213
  /** Data extracted by registered subprocess tool handlers (keyed by tool name) */
213
214
  extractedToolData?: Record<string, unknown[]>;
215
+ /**
216
+ * Auto-retry state when the subagent is sleeping between provider retries
217
+ * (e.g. 429 rate-limit with retry-after). Cleared when the retry resolves
218
+ * or fails. Surfacing this to the parent prevents the task tool from
219
+ * looking indefinitely "in progress" when a child is actually blocked on
220
+ * provider quota.
221
+ */
222
+ retryState?: {
223
+ attempt: number;
224
+ maxAttempts: number;
225
+ delayMs: number;
226
+ errorMessage: string;
227
+ startedAtMs: number;
228
+ };
229
+ /**
230
+ * Terminal retry failure surfaced once the subagent gave up retrying
231
+ * (e.g. retry-after exceeded the cap, or all attempts exhausted). Carries
232
+ * the final error so the parent UI can render "blocked: rate-limited"
233
+ * instead of waiting for a status that never arrives.
234
+ */
235
+ retryFailure?: {
236
+ attempt: number;
237
+ errorMessage: string;
238
+ };
214
239
  }
215
240
 
216
241
  /** Result from a single agent execution */
@@ -250,6 +275,16 @@ export interface SingleResult {
250
275
  nestedPatches?: NestedRepoPatch[];
251
276
  /** Data extracted by registered subprocess tool handlers (keyed by tool name) */
252
277
  extractedToolData?: Record<string, unknown[]>;
278
+ /**
279
+ * Terminal retry failure, when the subagent exited because the auto-retry
280
+ * loop gave up (retry-after exceeded the cap, or all attempts exhausted).
281
+ * Lets the parent task tool surface a "blocked: rate-limited" outcome
282
+ * instead of a generic failure.
283
+ */
284
+ retryFailure?: {
285
+ attempt: number;
286
+ errorMessage: string;
287
+ };
253
288
  /** Output metadata for agent:// URL integration */
254
289
  outputMeta?: { lineCount: number; charCount: number };
255
290
  }
@@ -35,13 +35,3 @@ export interface BashFixupResult {
35
35
  export function applyBashFixups(command: string): BashFixupResult {
36
36
  return nativeApplyBashFixups(command);
37
37
  }
38
-
39
- /**
40
- * Human-readable notice for the fixups that fired. Mirrors the shape of
41
- * `formatTimeoutClampNotice` so it can ride alongside the other bash notices.
42
- */
43
- export function formatBashFixupNotice(stripped: readonly string[]): string | undefined {
44
- if (!stripped.length) return undefined;
45
- const quoted = stripped.map(s => `\`${s}\``).join(", ");
46
- return `<system-warning>Stripped redundant ${quoted} — bash output is already truncated and stderr is already merged into stdout. NEVER use these patterns.</system-warning>`;
47
- }
package/src/tools/bash.ts CHANGED
@@ -17,7 +17,7 @@ import { renderStatusLine } from "../tui";
17
17
  import { CachedOutputBlock } from "../tui/output-block";
18
18
  import { getSixelLineMask } from "../utils/sixel";
19
19
  import type { ToolSession } from ".";
20
- import { applyBashFixups, formatBashFixupNotice } from "./bash-command-fixup";
20
+ import { applyBashFixups } from "./bash-command-fixup";
21
21
  import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive";
22
22
  import { checkBashInterception } from "./bash-interceptor";
23
23
  import { canUseInteractiveBashPty } from "./bash-pty-selection";
@@ -233,7 +233,6 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
233
233
  readonly #asyncEnabled: boolean;
234
234
  readonly #autoBackgroundEnabled: boolean;
235
235
  readonly #autoBackgroundThresholdMs: number;
236
- #bashFixupNoticeEmitted = false;
237
236
 
238
237
  constructor(private readonly session: ToolSession) {
239
238
  this.#asyncEnabled = this.session.settings.get("async.enabled");
@@ -475,12 +474,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
475
474
  // Apply conservative bash fixups (strip trailing `| head|tail` and redundant
476
475
  // `2>&1`). The helper is single-line only and refuses anything that could
477
476
  // change semantics.
478
- let bashFixups: string[] = [];
479
477
  if (this.session.settings.get("bash.stripTrailingHeadTail")) {
480
478
  const fixup = applyBashFixups(command);
481
479
  if (fixup.stripped.length > 0) {
482
480
  command = fixup.command;
483
- bashFixups = fixup.stripped;
484
481
  }
485
482
  }
486
483
 
@@ -562,11 +559,6 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
562
559
  const pendingNotices: string[] = [];
563
560
  const timeoutClampNotice = formatTimeoutClampNotice(requestedTimeoutSec, timeoutSec);
564
561
  if (timeoutClampNotice) pendingNotices.push(timeoutClampNotice);
565
- const bashFixupNotice = this.#bashFixupNoticeEmitted ? undefined : formatBashFixupNotice(bashFixups);
566
- if (bashFixupNotice) {
567
- pendingNotices.push(bashFixupNotice);
568
- this.#bashFixupNoticeEmitted = true;
569
- }
570
562
 
571
563
  if (asyncRequested) {
572
564
  if (!AsyncJobManager.instance()) {
@@ -2,7 +2,13 @@ import { execSync } from "node:child_process";
2
2
  import type { ClipboardImage } from "@oh-my-pi/pi-natives";
3
3
  import * as native from "@oh-my-pi/pi-natives";
4
4
 
5
- const hasDisplay = process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
5
+ function hasDisplay(): boolean {
6
+ return process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
7
+ }
8
+
9
+ function isWsl(): boolean {
10
+ return process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
11
+ }
6
12
 
7
13
  /**
8
14
  * Copy text to the system clipboard.
@@ -59,11 +65,66 @@ export async function copyToClipboard(text: string): Promise<void> {
59
65
  }
60
66
  }
61
67
 
68
+ // PowerShell one-liner that emits the clipboard image as base64-encoded PNG on
69
+ // stdout, or nothing when the clipboard does not hold image data. Used as the
70
+ // WSL bridge — arboard cannot read the Windows clipboard through WSLg.
71
+ const POWERSHELL_IMAGE_SCRIPT = `
72
+ $ErrorActionPreference = 'Stop'
73
+ Add-Type -AssemblyName System.Windows.Forms
74
+ Add-Type -AssemblyName System.Drawing
75
+ $img = [System.Windows.Forms.Clipboard]::GetImage()
76
+ if ($img -ne $null) {
77
+ $ms = New-Object System.IO.MemoryStream
78
+ $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
79
+ [Console]::Out.Write([Convert]::ToBase64String($ms.ToArray()))
80
+ }
81
+ `;
82
+
83
+ const POWERSHELL_TIMEOUT_MS = 5000;
84
+
85
+ /**
86
+ * Read a clipboard image through the Windows host's PowerShell.
87
+ *
88
+ * WSLg exposes a Wayland socket but no native clipboard image transport, so
89
+ * `arboard` returns `ContentNotAvailable`. PowerShell, reached via WSL interop,
90
+ * can read the Windows clipboard directly and round-trip the bitmap as PNG.
91
+ *
92
+ * Returns null when no image is on the clipboard, the host PowerShell is
93
+ * missing, or the bridge times out.
94
+ */
95
+ async function readImageViaPowerShell(): Promise<ClipboardImage | null> {
96
+ try {
97
+ const proc = Bun.spawn(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", POWERSHELL_IMAGE_SCRIPT], {
98
+ stdout: "pipe",
99
+ stderr: "ignore",
100
+ stdin: "ignore",
101
+ });
102
+ const timer = setTimeout(() => proc.kill(), POWERSHELL_TIMEOUT_MS);
103
+ let stdout = "";
104
+ try {
105
+ stdout = await new Response(proc.stdout).text();
106
+ await proc.exited;
107
+ } finally {
108
+ clearTimeout(timer);
109
+ }
110
+ if (proc.exitCode !== 0) return null;
111
+ const b64 = stdout.trim();
112
+ if (!b64) return null;
113
+ const bytes = Buffer.from(b64, "base64");
114
+ if (bytes.byteLength === 0) return null;
115
+ return { data: new Uint8Array(bytes), mimeType: "image/png" };
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
62
121
  /**
63
122
  * Read an image from the system clipboard.
64
123
  *
65
124
  * Returns null on Termux (no image clipboard support) or when no display
66
- * server is available (headless/SSH without forwarding).
125
+ * server is available (headless/SSH without forwarding). Under WSL the
126
+ * Windows clipboard is reached through `powershell.exe`, since WSLg's
127
+ * Wayland clipboard does not carry image payloads through to `arboard`.
67
128
  *
68
129
  * @returns PNG payload or null when no image is available.
69
130
  */
@@ -72,7 +133,11 @@ export async function readImageFromClipboard(): Promise<ClipboardImage | null> {
72
133
  return null;
73
134
  }
74
135
 
75
- if (!hasDisplay) {
136
+ if (isWsl()) {
137
+ const image = await readImageViaPowerShell();
138
+ if (image) return image;
139
+ // Fall through: arboard may still succeed on a future WSLg release.
140
+ } else if (!hasDisplay()) {
76
141
  return null;
77
142
  }
78
143