@oh-my-pi/pi-coding-agent 15.2.4 → 15.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/types/config/model-registry.d.ts +26 -0
  3. package/dist/types/config/settings-schema.d.ts +34 -1
  4. package/dist/types/config/settings.d.ts +6 -0
  5. package/dist/types/discovery/helpers.d.ts +1 -0
  6. package/dist/types/goals/runtime.d.ts +4 -0
  7. package/dist/types/modes/components/status-line/types.d.ts +10 -0
  8. package/dist/types/modes/components/status-line.d.ts +16 -0
  9. package/dist/types/modes/interactive-mode.d.ts +3 -1
  10. package/dist/types/modes/types.d.ts +3 -1
  11. package/dist/types/modes/utils/context-usage.d.ts +17 -0
  12. package/dist/types/modes/utils/ui-helpers.d.ts +5 -1
  13. package/dist/types/session/agent-session.d.ts +9 -0
  14. package/dist/types/session/session-manager.d.ts +10 -0
  15. package/dist/types/task/executor.d.ts +3 -1
  16. package/dist/types/task/types.d.ts +35 -0
  17. package/dist/types/tools/bash-command-fixup.d.ts +0 -5
  18. package/dist/types/utils/clipboard.d.ts +3 -1
  19. package/dist/types/utils/image-resize.d.ts +4 -1
  20. package/package.json +7 -7
  21. package/src/config/model-registry.ts +46 -21
  22. package/src/config/settings-schema.ts +29 -1
  23. package/src/config/settings.ts +19 -0
  24. package/src/discovery/helpers.ts +5 -1
  25. package/src/extensibility/plugins/legacy-pi-compat.ts +27 -5
  26. package/src/goals/runtime.ts +35 -13
  27. package/src/hashline/parser.ts +6 -1
  28. package/src/internal-urls/docs-index.generated.ts +2 -1
  29. package/src/main.ts +1 -1
  30. package/src/modes/components/model-selector.ts +53 -22
  31. package/src/modes/components/status-line/segments.ts +53 -0
  32. package/src/modes/components/status-line/types.ts +4 -0
  33. package/src/modes/components/status-line.ts +243 -15
  34. package/src/modes/controllers/command-controller.ts +9 -0
  35. package/src/modes/controllers/event-controller.ts +8 -0
  36. package/src/modes/interactive-mode.ts +23 -8
  37. package/src/modes/theme/theme.ts +1 -1
  38. package/src/modes/types.ts +1 -1
  39. package/src/modes/utils/context-usage.ts +42 -8
  40. package/src/modes/utils/ui-helpers.ts +11 -1
  41. package/src/prompts/agents/frontmatter.md +1 -0
  42. package/src/sdk.ts +24 -0
  43. package/src/session/agent-session.ts +70 -0
  44. package/src/session/session-manager.ts +119 -1
  45. package/src/slash-commands/builtin-registry.ts +15 -0
  46. package/src/task/executor.ts +50 -1
  47. package/src/task/index.ts +11 -0
  48. package/src/task/render.ts +26 -2
  49. package/src/task/types.ts +35 -0
  50. package/src/tools/bash-command-fixup.ts +0 -10
  51. package/src/tools/bash.ts +1 -9
  52. package/src/utils/clipboard.ts +79 -3
  53. package/src/utils/image-resize.ts +78 -30
  54. package/dist/types/modes/components/status-line-segment-editor.d.ts +0 -24
  55. package/src/modes/components/status-line-segment-editor.ts +0 -359
@@ -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";
@@ -749,6 +751,9 @@ export class AgentSession {
749
751
 
750
752
  // Event subscription state
751
753
  #unsubscribeAgent?: () => void;
754
+ #unsubscribeAppendOnly?: () => void;
755
+ /** Last (enable, providerId) tuple resolved by `#syncAppendOnlyContext` — used to skip no-op invalidations. */
756
+ #lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined };
752
757
  #eventListeners: AgentSessionEventListener[] = [];
753
758
 
754
759
  /** Tracks pending steering messages for UI display. Removed when delivered.
@@ -1138,6 +1143,8 @@ export class AgentSession {
1138
1143
  // Always subscribe to agent events for internal handling
1139
1144
  // (session persistence, hooks, auto-compaction, retry logic)
1140
1145
  this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
1146
+ // Re-evaluate append-only context mode when the setting changes at runtime.
1147
+ this.#unsubscribeAppendOnly = onAppendOnlyModeChanged(_value => this.#syncAppendOnlyContext(this.model));
1141
1148
  }
1142
1149
 
1143
1150
  /** Model registry for API key resolution and model discovery */
@@ -2781,6 +2788,10 @@ export class AgentSession {
2781
2788
  await hindsightState?.flushRetainQueue();
2782
2789
  hindsightState?.dispose();
2783
2790
  this.#disconnectFromAgent();
2791
+ if (this.#unsubscribeAppendOnly) {
2792
+ this.#unsubscribeAppendOnly();
2793
+ this.#unsubscribeAppendOnly = undefined;
2794
+ }
2784
2795
  this.#eventListeners = [];
2785
2796
  }
2786
2797
 
@@ -3573,6 +3584,18 @@ export class AgentSession {
3573
3584
  return this.#autoCompactionAbortController !== undefined || this.#compactionAbortController !== undefined;
3574
3585
  }
3575
3586
 
3587
+ /**
3588
+ * Whether idle-flush tasks, auto-continuations, or other short-lived
3589
+ * post-prompt work are pending. True in the brief window after
3590
+ * `session.prompt()` returns but before a scheduled background delivery
3591
+ * (e.g. an async-job result) has finished its own streaming turn.
3592
+ * Loop-mode and similar auto-submit paths should treat this as a block
3593
+ * to avoid racing against the delivery turn.
3594
+ */
3595
+ get hasPostPromptWork(): boolean {
3596
+ return this.#postPromptTasks.size > 0;
3597
+ }
3598
+
3576
3599
  /** All messages including custom types like BashExecutionMessage */
3577
3600
  get messages(): AgentMessage[] {
3578
3601
  return this.agent.state.messages;
@@ -5947,6 +5970,9 @@ export class AgentSession {
5947
5970
  this.#closeProviderSessionsForModelSwitch(currentModel, model);
5948
5971
  }
5949
5972
  this.agent.setModel(model);
5973
+
5974
+ // Re-evaluate append-only context mode — provider or setting may have changed
5975
+ this.#syncAppendOnlyContext(model);
5950
5976
  }
5951
5977
 
5952
5978
  #closeCodexProviderSessionsForHistoryRewrite(): void {
@@ -5955,6 +5981,29 @@ export class AgentSession {
5955
5981
  this.#closeProviderSessionsForModelSwitch(currentModel, currentModel);
5956
5982
  }
5957
5983
 
5984
+ /**
5985
+ * Re-evaluate append-only context mode, creating or destroying the
5986
+ * manager as needed. Called on model switch AND setting change.
5987
+ */
5988
+ #syncAppendOnlyContext(model: Model | null | undefined): void {
5989
+ const setting = this.settings.get("provider.appendOnlyContext") ?? "auto";
5990
+ const providerId = model?.provider;
5991
+ const enable = setting === "on" || (setting === "auto" && providerId === "deepseek");
5992
+ const prev = this.#lastAppendOnlyResolution;
5993
+ if (prev && prev.enable === enable && prev.providerId === providerId) return;
5994
+ this.#lastAppendOnlyResolution = { enable, providerId };
5995
+
5996
+ if (enable && !this.agent.appendOnlyContext) {
5997
+ this.agent.setAppendOnlyContext(new AppendOnlyContextManager());
5998
+ } else if (enable && this.agent.appendOnlyContext) {
5999
+ // Already active — invalidate prefix + log so the next turn
6000
+ // rebuilds for the current model's normalization.
6001
+ this.agent.appendOnlyContext.invalidateForModelChange();
6002
+ } else if (!enable && this.agent.appendOnlyContext) {
6003
+ this.agent.setAppendOnlyContext(undefined);
6004
+ }
6005
+ }
6006
+
5958
6007
  #closeProviderSessionsForModelSwitch(currentModel: Model, nextModel: Model): void {
5959
6008
  const providerKeys = new Set<string>();
5960
6009
  if (currentModel.api === "openai-codex-responses" || nextModel.api === "openai-codex-responses") {
@@ -7071,6 +7120,27 @@ export class AgentSession {
7071
7120
  }
7072
7121
  }
7073
7122
 
7123
+ // Fail-fast cap: if the provider asks us to wait longer than
7124
+ // retry.maxDelayMs and we have no fallback credential or model to
7125
+ // switch to, surface the error instead of sleeping. Defends against
7126
+ // 3-hour Anthropic rate-limit windows that would otherwise leave a
7127
+ // subagent (or interactive session) silently hung. The original
7128
+ // assistant error message is preserved in agent state so the caller
7129
+ // can act on it.
7130
+ const maxDelayMs = retrySettings.maxDelayMs;
7131
+ if (maxDelayMs > 0 && delayMs > maxDelayMs && !switchedCredential && !switchedModel) {
7132
+ const attempt = this.#retryAttempt;
7133
+ this.#retryAttempt = 0;
7134
+ await this.#emitSessionEvent({
7135
+ type: "auto_retry_end",
7136
+ success: false,
7137
+ attempt,
7138
+ finalError: `Provider requested ${delayMs}ms wait, exceeds retry.maxDelayMs (${maxDelayMs}ms). Original error: ${errorMessage}`,
7139
+ });
7140
+ this.#resolveRetry();
7141
+ return false;
7142
+ }
7143
+
7074
7144
  await this.#emitSessionEvent({
7075
7145
  type: "auto_retry_start",
7076
7146
  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,
@@ -941,12 +942,71 @@ function extractFirstUserPrompt(entries: Array<Record<string, unknown>>): string
941
942
  return undefined;
942
943
  }
943
944
 
945
+ /**
946
+ * Promote orphaned `<basename>.jsonl.<snowflake>.bak` backups created by
947
+ * `#replaceSessionFileAfterEperm` back to their primary path when the primary
948
+ * is missing. This runs once per session-dir scan, before the main `*.jsonl`
949
+ * glob, so a crash between the two renames in the EPERM-rewrite path does not
950
+ * leave the user's last good state stranded outside the loader's view.
951
+ *
952
+ * Exported for testing.
953
+ */
954
+ export async function recoverOrphanedBackups(sessionDir: string, storage: SessionStorage): Promise<void> {
955
+ let backups: string[];
956
+ try {
957
+ backups = storage.listFilesSync(sessionDir, "*.bak");
958
+ } catch {
959
+ return;
960
+ }
961
+ if (backups.length === 0) return;
962
+ // For each primary path, pick the newest backup (highest mtime) as the recovery source.
963
+ const candidates = new Map<string, { backup: string; mtimeMs: number }>();
964
+ for (const backup of backups) {
965
+ const name = path.basename(backup);
966
+ // Expect "<primary>.<snowflake>.bak" where <primary> ends in ".jsonl".
967
+ if (!name.endsWith(".bak")) continue;
968
+ const trimmed = name.slice(0, -".bak".length);
969
+ const dotIdx = trimmed.lastIndexOf(".");
970
+ if (dotIdx <= 0) continue;
971
+ const primaryName = trimmed.slice(0, dotIdx);
972
+ if (!primaryName.endsWith(".jsonl")) continue;
973
+ const primaryPath = path.join(sessionDir, primaryName);
974
+ let mtimeMs = 0;
975
+ try {
976
+ mtimeMs = storage.statSync(backup).mtimeMs;
977
+ } catch {
978
+ continue;
979
+ }
980
+ const existing = candidates.get(primaryPath);
981
+ if (!existing || mtimeMs > existing.mtimeMs) {
982
+ candidates.set(primaryPath, { backup, mtimeMs });
983
+ }
984
+ }
985
+ for (const [primaryPath, { backup }] of candidates) {
986
+ if (storage.existsSync(primaryPath)) continue;
987
+ try {
988
+ await storage.rename(backup, primaryPath);
989
+ logger.warn("Recovered orphaned session backup", {
990
+ sessionFile: primaryPath,
991
+ backupPath: backup,
992
+ });
993
+ } catch (err) {
994
+ logger.warn("Failed to recover orphaned session backup", {
995
+ sessionFile: primaryPath,
996
+ backupPath: backup,
997
+ error: toError(err).message,
998
+ });
999
+ }
1000
+ }
1001
+ }
1002
+
944
1003
  /**
945
1004
  * Reads all session files from the directory and returns them sorted by mtime (newest first).
946
1005
  * Uses low-level file I/O to efficiently read only the first 4KB of each file
947
1006
  * to extract the JSON header and first user message without loading entire session logs into memory.
948
1007
  */
949
1008
  async function getSortedSessions(sessionDir: string, storage: SessionStorage): Promise<RecentSessionInfo[]> {
1009
+ await recoverOrphanedBackups(sessionDir, storage);
950
1010
  try {
951
1011
  const files: string[] = storage.listFilesSync(sessionDir, "*.jsonl");
952
1012
  const sessions: RecentSessionInfo[] = [];
@@ -2146,7 +2206,64 @@ export class SessionManager {
2146
2206
  { ignoreError: true },
2147
2207
  );
2148
2208
  }
2209
+ // Windows can reject overwrite-style rename with EPERM even after our own writer is closed.
2210
+ // Move the old session file aside first so a failed retry can roll back to the last good file.
2211
+ // The backup uses a plain `<basename>.<snowflake>.bak` name (no leading dot) so that if the
2212
+ // process crashes between the two renames, `recoverOrphanedBackups` can find it via the
2213
+ // shared `*.bak` glob on both real and in-memory storage backends and promote it back to
2214
+ // the primary on the next session-dir scan.
2149
2215
 
2216
+ async #replaceSessionFileAfterEperm(tempPath: string, targetPath: string, renameError: unknown): Promise<void> {
2217
+ const dir = path.resolve(targetPath, "..");
2218
+ const backupPath = path.join(dir, `${path.basename(targetPath)}.${Snowflake.next()}.bak`);
2219
+ try {
2220
+ await this.storage.rename(targetPath, backupPath);
2221
+ } catch (err) {
2222
+ if (isEnoent(err)) {
2223
+ await this.storage.rename(tempPath, targetPath);
2224
+ return;
2225
+ }
2226
+ throw toError(renameError);
2227
+ }
2228
+
2229
+ try {
2230
+ await this.storage.rename(tempPath, targetPath);
2231
+ } catch (err) {
2232
+ const replaceError = toError(err);
2233
+ const originalError = toError(renameError);
2234
+ try {
2235
+ await this.storage.rename(backupPath, targetPath);
2236
+ } catch (rollbackErr) {
2237
+ const rollbackError = toError(rollbackErr);
2238
+ throw new Error(
2239
+ `Failed to replace session file after EPERM (original: ${originalError.message}; retry: ${replaceError.message}); rollback from ${backupPath} also failed: ${rollbackError.message}`,
2240
+ { cause: originalError },
2241
+ );
2242
+ }
2243
+ throw replaceError;
2244
+ }
2245
+
2246
+ try {
2247
+ await this.storage.unlink(backupPath);
2248
+ } catch (err) {
2249
+ if (!isEnoent(err)) {
2250
+ logger.warn("Failed to remove session rewrite backup", {
2251
+ sessionFile: targetPath,
2252
+ backupPath,
2253
+ error: toError(err).message,
2254
+ });
2255
+ }
2256
+ }
2257
+ }
2258
+
2259
+ async #replaceSessionFile(tempPath: string, targetPath: string): Promise<void> {
2260
+ try {
2261
+ await this.storage.rename(tempPath, targetPath);
2262
+ } catch (err) {
2263
+ if (!hasFsCode(err, "EPERM")) throw toError(err);
2264
+ await this.#replaceSessionFileAfterEperm(tempPath, targetPath, err);
2265
+ }
2266
+ }
2150
2267
  async #writeEntriesAtomically(entries: FileEntry[]): Promise<void> {
2151
2268
  if (!this.#sessionFile) return;
2152
2269
  const dir = path.resolve(this.#sessionFile, "..");
@@ -2159,7 +2276,7 @@ export class SessionManager {
2159
2276
  await writer.flush();
2160
2277
  await writer.fsync();
2161
2278
  await writer.close();
2162
- await this.storage.rename(tempPath, this.#sessionFile);
2279
+ await this.#replaceSessionFile(tempPath, this.#sessionFile);
2163
2280
  } catch (err) {
2164
2281
  try {
2165
2282
  await writer.close();
@@ -3191,6 +3308,7 @@ export class SessionManager {
3191
3308
  ): Promise<SessionInfo[]> {
3192
3309
  const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
3193
3310
  try {
3311
+ await recoverOrphanedBackups(dir, storage);
3194
3312
  const files = storage.listFilesSync(dir, "*.jsonl");
3195
3313
  return await collectSessionsFromFiles(files, storage);
3196
3314
  } catch {
@@ -72,7 +72,16 @@ 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;
76
+ // Capture state BEFORE the call: when plan mode is already active,
77
+ // handlePlanModeCommand may exit it (on confirmed exit) or leave it on (on cancel
78
+ // or warning). In every "already active" case the typed args are NOT consumed,
79
+ // so preserve them in history regardless of the user's confirm/cancel choice.
80
+ const wasPlanModeEnabled = runtime.ctx.planModeEnabled;
75
81
  await runtime.ctx.handlePlanModeCommand(command.args || undefined);
82
+ if (hadArgs && wasPlanModeEnabled) {
83
+ runtime.ctx.editor.addToHistory(command.text);
84
+ }
76
85
  runtime.ctx.editor.setText("");
77
86
  },
78
87
  },
@@ -90,7 +99,13 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
90
99
  inlineHint: "[objective]",
91
100
  allowArgs: true,
92
101
  handleTui: async (command, runtime) => {
102
+ const hadArgs = !!command.args;
103
+ // Capture state BEFORE the call (see /plan above for rationale).
104
+ const wasGoalModeEnabled = runtime.ctx.goalModeEnabled;
93
105
  await runtime.ctx.handleGoalModeCommand(command.args || undefined);
106
+ if (hadArgs && wasGoalModeEnabled) {
107
+ runtime.ctx.editor.addToHistory(command.text);
108
+ }
94
109
  runtime.ctx.editor.setText("");
95
110
  },
96
111
  },
@@ -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()) {