@oh-my-pi/pi-coding-agent 15.2.3 → 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 (72) hide show
  1. package/CHANGELOG.md +41 -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/hashline/constants.d.ts +0 -2
  7. package/dist/types/hashline/hash.d.ts +13 -39
  8. package/dist/types/hashline/parser.d.ts +2 -6
  9. package/dist/types/modes/components/status-line/types.d.ts +10 -0
  10. package/dist/types/modes/components/status-line.d.ts +10 -0
  11. package/dist/types/modes/interactive-mode.d.ts +3 -1
  12. package/dist/types/modes/shared.d.ts +9 -0
  13. package/dist/types/modes/theme/shimmer.d.ts +6 -3
  14. package/dist/types/modes/types.d.ts +3 -1
  15. package/dist/types/modes/utils/context-usage.d.ts +17 -0
  16. package/dist/types/modes/utils/ui-helpers.d.ts +5 -1
  17. package/dist/types/session/agent-session.d.ts +9 -0
  18. package/dist/types/task/executor.d.ts +3 -1
  19. package/dist/types/task/types.d.ts +35 -0
  20. package/dist/types/tools/bash-command-fixup.d.ts +0 -5
  21. package/dist/types/utils/clipboard.d.ts +3 -1
  22. package/dist/types/utils/image-resize.d.ts +4 -1
  23. package/package.json +7 -7
  24. package/src/config/prompt-templates.ts +1 -8
  25. package/src/config/settings-schema.ts +29 -1
  26. package/src/config/settings.ts +19 -0
  27. package/src/discovery/helpers.ts +5 -1
  28. package/src/edit/index.ts +1 -1
  29. package/src/edit/renderer.ts +5 -7
  30. package/src/edit/streaming.ts +24 -12
  31. package/src/extensibility/plugins/legacy-pi-compat.ts +27 -5
  32. package/src/goals/runtime.ts +35 -13
  33. package/src/hashline/constants.ts +0 -3
  34. package/src/hashline/diff.ts +1 -1
  35. package/src/hashline/execute.ts +2 -2
  36. package/src/hashline/grammar.lark +7 -8
  37. package/src/hashline/hash.ts +21 -43
  38. package/src/hashline/input.ts +15 -13
  39. package/src/hashline/parser.ts +62 -161
  40. package/src/internal-urls/docs-index.generated.ts +2 -2
  41. package/src/main.ts +1 -1
  42. package/src/modes/components/model-selector.ts +53 -22
  43. package/src/modes/components/status-line/segments.ts +53 -0
  44. package/src/modes/components/status-line/types.ts +4 -0
  45. package/src/modes/components/status-line.ts +147 -12
  46. package/src/modes/controllers/command-controller.ts +9 -0
  47. package/src/modes/controllers/event-controller.ts +10 -1
  48. package/src/modes/interactive-mode.ts +74 -18
  49. package/src/modes/shared.ts +16 -0
  50. package/src/modes/theme/shimmer.ts +15 -6
  51. package/src/modes/theme/theme.ts +1 -1
  52. package/src/modes/types.ts +1 -1
  53. package/src/modes/utils/context-usage.ts +25 -2
  54. package/src/modes/utils/ui-helpers.ts +11 -1
  55. package/src/prompts/agents/frontmatter.md +1 -0
  56. package/src/prompts/tools/hashline.md +62 -81
  57. package/src/sdk.ts +24 -0
  58. package/src/session/agent-session.ts +58 -0
  59. package/src/session/session-manager.ts +54 -1
  60. package/src/slash-commands/builtin-registry.ts +10 -0
  61. package/src/task/executor.ts +50 -1
  62. package/src/task/index.ts +11 -0
  63. package/src/task/render.ts +26 -2
  64. package/src/task/types.ts +35 -0
  65. package/src/tools/bash-command-fixup.ts +0 -10
  66. package/src/tools/bash.ts +1 -9
  67. package/src/utils/clipboard.ts +68 -3
  68. package/src/utils/commit-message-generator.ts +6 -1
  69. package/src/utils/image-resize.ts +51 -26
  70. package/src/utils/title-generator.ts +45 -13
  71. package/dist/types/modes/components/status-line-segment-editor.d.ts +0 -24
  72. package/src/modes/components/status-line-segment-editor.ts +0 -359
@@ -6,5 +6,6 @@ description: {{jsonStringify description}}
6
6
  {{/if}}{{#if model}}model: {{jsonStringify model}}
7
7
  {{/if}}{{#if thinkingLevel}}thinking-level: {{jsonStringify thinkingLevel}}
8
8
  {{/if}}{{#if blocking}}blocking: true
9
+ {{/if}}{{#if autoloadSkills}}autoloadSkills: {{jsonStringify autoloadSkills}}
9
10
  {{/if}}---
10
11
  {{body}}
@@ -1,58 +1,36 @@
1
1
  Your patch language is a compact, line-anchored edit format.
2
2
 
3
- A patch contains one or more file sections. The first non-blank line of every edit section MUST be `@@ PATH`.
3
+ A patch contains one or more file sections. The first non-blank line of every edit section MUST be PATH`.
4
4
  Operations reference lines in the file by their line number and hash, called "Anchors", e.g. `5th`, `123ab`.
5
5
  You MUST copy them verbatim from the latest output for the file you're editing.
6
6
 
7
7
  Purely textual format. The tool has NO awareness of language, indentation, brackets, fences, or table widths. You MUST emit valid syntax in replacements/insertions.
8
8
 
9
9
  <ops>
10
- @@ PATH header: subsequent ops apply to PATH
10
+ §PATH header: subsequent ops apply to PATH
11
11
  Each op line is ONE of:
12
- + ANCHOR insert lines AFTER the anchored line (or EOF); payload follows as `{{hsep}}TEXT` lines
13
- < ANCHOR insert lines BEFORE the anchored line (or BOF); payload follows as `{{hsep}}TEXT` lines
14
- - A..B delete the line range (inclusive).
15
- = A..B replace the range with payload `{{hsep}}TEXT` lines, or with one blank line if no payload follows.
12
+ »ANCHOR insert lines AFTER the anchored line (or EOF); payload follows on subsequent lines
13
+ «ANCHOR insert lines BEFORE the anchored line (or BOF); payload follows on subsequent lines
14
+ A..B replace the inclusive range A..B with payload; delete the range if no payload follows
15
+ ≔A shorthand for ≔A..A
16
16
  </ops>
17
17
 
18
- <format-reminder>
19
- Op lines carry no content — payload goes on the next line.
20
-
21
- WRONG: + 5pg| some code
22
- WRONG: {{hsep}} some code
23
- RIGHT: + 5pg
24
- {{hsep}}some code
25
-
26
- A single `+`/`<`/`=` op accepts MANY `{{hsep}}` payload lines. To insert N consecutive lines, write ONE op followed by N payload lines — NEVER N ops with one payload each.
27
-
28
- WRONG (one op per inserted line, with fabricated anchors):
29
- + 5pg
30
- {{hsep}}first new line
31
- + 6xx ← FABRICATED
32
- {{hsep}}second new line
33
-
34
- RIGHT (one op, many payload lines):
35
- + 5pg
36
- {{hsep}}first new line
37
- {{hsep}}second new line
38
- </format-reminder>
39
-
40
18
  <rules>
41
- - Every payload line MUST start with `{{hsep}}` immediately followed by payload text. Do NOT add a readability space after `{{hsep}}`.
42
- - Every character after `{{hsep}}` is file content. If the target line intentionally starts with one space, write exactly one space after `{{hsep}}`; otherwise write none.
43
19
  - Payload text is verbatim — NEVER escape unicode.
20
+ - Payload ends at the next `»`, `«`, `≔`, `§`, envelope marker, or EOF.
21
+ - `≔A..B` with no payload deletes the range. To keep a blank line, include one explicit empty payload line.
44
22
  - **Payload is only what's NEW relative to your range:**
45
- - `=` replaces inside; NEVER include lines outside.
46
- - `+`/`<` adds at the anchor; NEVER repeat line A or neighbors.
23
+ - `≔` replaces inside; NEVER include lines outside.
24
+ - `»`/`«` adds at the anchor; NEVER repeat line A or neighbors.
47
25
  - Payload matching nearby content duplicates — drop it or widen.
48
26
  - **Pick a self-contained unit first.** Touching a multiline construct? Widen to the whole thing.
49
- - Then smallest op: add → `+`/`<`; delete → `-`; `=` ONLY when modifying inside.
27
+ - Then smallest op: add → `»`/`«`; delete/replace`≔`.
50
28
  </rules>
51
29
 
52
30
  <brace-shapes>
53
31
  When braces bound your edit, you SHOULD prefer these shapes:
54
32
  - **Whole block**: range spans `{` through matching `}`.
55
- - **Signature only**: one-line `=` on the opener; body untouched.
33
+ - **Signature only**: one-line `≔` on the opener; body untouched.
56
34
  - **Insert inside**: anchor on `{` or last interior line; NEVER repeat the braces.
57
35
  - **End on `}`**: only when that `}` is part of the change. Otherwise extend or stop earlier.
58
36
  </brace-shapes>
@@ -61,9 +39,9 @@ When braces bound your edit, you SHOULD prefer these shapes:
61
39
  - **NEVER replay past your range.** Stop before B+1; extend B if it must go.
62
40
  - **NEVER duplicate chunks inside one payload.** Caught re-emitting? Rewrite.
63
41
  - **Anchor only inside the visible region.** B+1 truncated? Re-`read` first.
64
- - **You SHOULD prefer the narrowest self-contained edit.** Small `+`/`-` beats wide `=`.
42
+ - **You SHOULD prefer the narrowest self-contained edit.** Narrow range beats wide range.
65
43
  - **Anchors reference the file as last read.** NEVER shift for prior ops.
66
- - **One `+`/`<` op per block, NOT per line.** N lines = ONE op, N payloads. Collapse adjacent ops.
44
+ - **One `»`/`«` op per block, NOT per line.** N lines = ONE op, N payloads. Collapse adjacent ops.
67
45
  - **NEVER fabricate anchor hashes.** Missing? Re-`read`.
68
46
  </common-failures>
69
47
 
@@ -79,71 +57,74 @@ When braces bound your edit, you SHOULD prefer these shapes:
79
57
 
80
58
  <examples>
81
59
  # Replace one line (the payload must re-emit the original indentation)
82
- @@ mod.ts
83
- = {{hrefr 1}}..{{hrefr 1}}
84
- {{hsep}}const TITLE = "Mrs";
60
+ §mod.ts
61
+ {{hrefr 1}}
62
+ const TITLE = "Mrs";
85
63
 
86
64
  # Replace a full multiline statement (widen to a self-contained boundary)
87
- @@ mod.ts
88
- = {{hrefr 3}}..{{hrefr 6}}
89
- {{hsep}} return [
90
- {{hsep}} "Mrs",
91
- {{hsep}} name?.trim() || "guest",
92
- {{hsep}} ].join(" ");
65
+ §mod.ts
66
+ {{hrefr 3}}..{{hrefr 6}}
67
+ return [
68
+ "Mrs",
69
+ name?.trim() || "guest",
70
+ ].join(" ");
93
71
 
94
72
  # Insert AFTER/BEFORE a line
95
- @@ mod.ts
96
- + {{hrefr 4}}
97
- {{hsep}} "Dr",
98
- < {{hrefr 5}}
99
- {{hsep}} "Dr",
73
+ §mod.ts
74
+ »{{hrefr 4}}
75
+ "Dr",
76
+ «{{hrefr 5}}
77
+ "Dr",
100
78
 
101
79
  # Append to file
102
- @@ mod.ts
103
- + EOF
104
- {{hsep}}export const done = true;
80
+ §mod.ts
81
+ »EOF
82
+ export const done = true;
105
83
 
106
84
  # Delete a line
107
- @@ mod.ts
108
- - {{hrefr 5}}..{{hrefr 5}}
85
+ §mod.ts
86
+ {{hrefr 5}}
87
+
88
+ # Blank a line (replace with LF: the empty payload is the blank line before `»EOF`)
89
+ §mod.ts
90
+ ≔{{hrefr 5}}
109
91
 
110
- # Blank a line (replace with LF)
111
- @@ mod.ts
112
- = {{hrefr 5}}..{{hrefr 5}}
92
+ »EOF
93
+ export const done = true;
113
94
  </examples>
114
95
 
115
96
  <anti-pattern>
116
97
  # WRONG — replaces 2 lines just to add one.
117
- @@ mod.ts
118
- = {{hrefr 1}}..{{hrefr 2}}
119
- {{hsep}}const TITLE = "Mr";
120
- {{hsep}}const DEBUG = false;
121
- {{hsep}}export function greet(name) {
98
+ §mod.ts
99
+ {{hrefr 1}}..{{hrefr 2}}
100
+ const TITLE = "Mr";
101
+ const DEBUG = false;
102
+ export function greet(name) {
122
103
  # RIGHT — same effect, one-line insert
123
- @@ mod.ts
124
- + {{hrefr 1}}
125
- {{hsep}}const DEBUG = false;
104
+ §mod.ts
105
+ »{{hrefr 1}}
106
+ const DEBUG = false;
126
107
 
127
108
  # WRONG — replace from the middle of a larger statement (error-prone)
128
- @@ mod.ts
129
- = {{hrefr 4}}..{{hrefr 5}}
130
- {{hsep}} "Dr",
131
- {{hsep}} name?.trim() || "guest",
109
+ §mod.ts
110
+ {{hrefr 4}}..{{hrefr 5}}
111
+ "Dr",
112
+ name?.trim() || "guest",
132
113
  # RIGHT — widen to the full statement
133
- @@ mod.ts
134
- = {{hrefr 3}}..{{hrefr 6}}
135
- {{hsep}} return [
136
- {{hsep}} "Dr",
137
- {{hsep}} name?.trim() || "guest",
138
- {{hsep}} ].join(" ");
114
+ §mod.ts
115
+ {{hrefr 3}}..{{hrefr 6}}
116
+ return [
117
+ "Dr",
118
+ name?.trim() || "guest",
119
+ ].join(" ");
139
120
  </anti-pattern>
140
121
 
141
122
  <critical>
142
123
  - Copy anchors verbatim (line number + 2-char hash); NEVER include the `|TEXT` body.
143
- - Every payload line MUST start with `{{hsep}}`; raw content is invalid.
144
- - NEVER write unified diff syntax. Header is `@@ PATH`; ops are `<`/`+`/`-`/`=`.
145
- - `= A..B` deletes the range; payload is what's written. Edge line matches just outside? Widen, or it duplicates.
146
- - Multiple ops are cheap. SHOULD prefer two narrow ops over one wide `=`.
147
- - Before `= A..B`, mentally delete A..B. Splits an unclosed bracket/brace/string from above, or orphans a closer inside? You're bisecting a construct.
124
+ - NEVER write unified diff syntax. Headers are `§PATH`; ops are `»`/`«`/`≔`.
125
+ - `≔A..B` deletes the range when no payload follows. To keep a blank line, include one explicit empty payload line.
126
+ - `≔A..B` with payload writes exactly that payload. Edge line matches just outside? Widen, or it duplicates.
127
+ - Multiple ops are cheap. SHOULD prefer two narrow ops over one wide `≔`.
128
+ - Before `≔A..B`, mentally delete A..B. Splits an unclosed bracket/brace/string from above, or orphans a closer inside? You're bisecting a construct.
148
129
  - NEVER use this tool to reformat code (indentation, whitespace, line wrapping, style). Run the project's formatter instead.
149
130
  </critical>
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