@oh-my-pi/pi-coding-agent 15.1.9 → 15.2.2

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 (64) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/dist/types/config/settings-schema.d.ts +10 -0
  3. package/dist/types/eval/py/kernel.d.ts +6 -0
  4. package/dist/types/goals/state.d.ts +1 -1
  5. package/dist/types/goals/tools/goal-tool.d.ts +4 -0
  6. package/dist/types/hashline/parser.d.ts +6 -2
  7. package/dist/types/internal-urls/memory-protocol.d.ts +6 -0
  8. package/dist/types/modes/theme/shimmer.d.ts +27 -0
  9. package/dist/types/slash-commands/helpers/format.d.ts +4 -1
  10. package/dist/types/tools/ast-edit.d.ts +3 -0
  11. package/dist/types/tools/ast-grep.d.ts +3 -0
  12. package/dist/types/tools/browser/launch.d.ts +2 -0
  13. package/dist/types/tools/find.d.ts +3 -0
  14. package/dist/types/tools/search.d.ts +3 -0
  15. package/dist/types/tui/file-list.d.ts +6 -0
  16. package/dist/types/tui/hyperlink.d.ts +42 -0
  17. package/dist/types/tui/index.d.ts +1 -0
  18. package/dist/types/web/search/providers/utils.d.ts +2 -1
  19. package/package.json +7 -7
  20. package/src/config/settings-schema.ts +12 -0
  21. package/src/config/settings.ts +28 -5
  22. package/src/discovery/builtin.ts +30 -0
  23. package/src/edit/renderer.ts +5 -3
  24. package/src/eval/py/executor.ts +12 -1
  25. package/src/eval/py/kernel.ts +24 -8
  26. package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
  27. package/src/goals/runtime.ts +9 -3
  28. package/src/goals/state.ts +1 -1
  29. package/src/goals/tools/goal-tool.ts +12 -2
  30. package/src/hashline/diff.ts +1 -1
  31. package/src/hashline/execute.ts +2 -2
  32. package/src/hashline/parser.ts +87 -12
  33. package/src/internal-urls/memory-protocol.ts +1 -1
  34. package/src/modes/interactive-mode.ts +29 -1
  35. package/src/modes/theme/shimmer.ts +79 -0
  36. package/src/prompts/tools/goal.md +7 -2
  37. package/src/session/agent-session.ts +18 -75
  38. package/src/slash-commands/helpers/format.ts +23 -3
  39. package/src/task/executor.ts +115 -19
  40. package/src/tools/ast-edit.ts +39 -6
  41. package/src/tools/ast-grep.ts +38 -6
  42. package/src/tools/browser/launch.ts +63 -51
  43. package/src/tools/find.ts +13 -2
  44. package/src/tools/read.ts +46 -6
  45. package/src/tools/search.ts +447 -265
  46. package/src/tui/file-list.ts +10 -2
  47. package/src/tui/hyperlink.ts +126 -0
  48. package/src/tui/index.ts +1 -0
  49. package/src/web/search/index.ts +13 -9
  50. package/src/web/search/providers/anthropic.ts +3 -1
  51. package/src/web/search/providers/brave.ts +3 -1
  52. package/src/web/search/providers/codex.ts +3 -1
  53. package/src/web/search/providers/exa.ts +3 -1
  54. package/src/web/search/providers/gemini.ts +3 -1
  55. package/src/web/search/providers/jina.ts +3 -1
  56. package/src/web/search/providers/kagi.ts +5 -1
  57. package/src/web/search/providers/kimi.ts +3 -1
  58. package/src/web/search/providers/parallel.ts +5 -1
  59. package/src/web/search/providers/perplexity.ts +5 -1
  60. package/src/web/search/providers/searxng.ts +3 -1
  61. package/src/web/search/providers/synthetic.ts +3 -1
  62. package/src/web/search/providers/tavily.ts +3 -1
  63. package/src/web/search/providers/utils.ts +33 -1
  64. package/src/web/search/providers/zai.ts +3 -1
@@ -154,7 +154,7 @@ interface LegacyPiMirrorState {
154
154
  function getMirrorPath(sourcePath: string, state: LegacyPiMirrorState): string {
155
155
  const extension = path.extname(sourcePath) || ".js";
156
156
  const digest = Bun.hash(sourcePath).toString(36);
157
- return path.join(state.root, `${digest}${extension}`);
157
+ return path.join(state.root, `module-${digest}${extension}`);
158
158
  }
159
159
 
160
160
  async function rewriteRelativeImportsForLegacyExtension(
@@ -212,7 +212,7 @@ async function mirrorLegacyPiFile(sourcePath: string, state: LegacyPiMirrorState
212
212
  }
213
213
 
214
214
  export async function loadLegacyPiModule(resolvedPath: string): Promise<unknown> {
215
- const root = path.join(os.tmpdir(), "omp-legacy-pi-file", Bun.hash(resolvedPath).toString(36));
215
+ const root = path.join(os.tmpdir(), "omp-legacy-pi-file", `entry-${Bun.hash(resolvedPath).toString(36)}`);
216
216
  await fs.rm(root, { recursive: true, force: true });
217
217
  const state: LegacyPiMirrorState = { root, seen: new Map() };
218
218
  const mirroredEntry = await mirrorLegacyPiFile(resolvedPath, state);
@@ -379,7 +379,7 @@ export class GoalRuntime {
379
379
  validateTokenBudget(input.tokenBudget);
380
380
  return await this.#withAccounting(async () => {
381
381
  const existing = this.#host.getState();
382
- if (existing?.goal && existing.goal.status !== "dropped") {
382
+ if (existing?.goal && existing.goal.status !== "dropped" && existing.goal.status !== "complete") {
383
383
  throw new Error("cannot create a new goal because this session already has a goal");
384
384
  }
385
385
  const now = this.#now();
@@ -459,8 +459,14 @@ export class GoalRuntime {
459
459
  return await this.#withAccounting(async () => {
460
460
  await this.#flushUsageLocked("suppressed");
461
461
  const state = this.#getStateClone();
462
- if (!state?.enabled || !state.goal) {
463
- throw new Error("cannot complete goal because goal mode is not active");
462
+ if (!state?.goal) {
463
+ throw new Error("cannot complete goal because no goal is active");
464
+ }
465
+ if (state.goal.status === "complete") {
466
+ throw new Error("goal is already complete");
467
+ }
468
+ if (state.goal.status === "dropped") {
469
+ throw new Error("cannot complete a dropped goal");
464
470
  }
465
471
  state.enabled = false;
466
472
  state.goal.status = "complete";
@@ -21,7 +21,7 @@ export interface GoalModeState {
21
21
  }
22
22
 
23
23
  export interface GoalToolDetails {
24
- op: "create" | "get" | "complete";
24
+ op: "create" | "get" | "complete" | "resume" | "drop";
25
25
  goal?: Goal | null;
26
26
  remainingTokens?: number | null;
27
27
  completionBudgetReport?: string | null;
@@ -15,7 +15,7 @@ import { completionBudgetReport, remainingTokens } from "../runtime";
15
15
  import type { Goal, GoalStatus, GoalToolDetails } from "../state";
16
16
 
17
17
  const goalSchema = z.object({
18
- op: z.enum(["create", "get", "complete"]).describe("goal operation"),
18
+ op: z.enum(["create", "get", "complete", "resume", "drop"]).describe("goal operation"),
19
19
  objective: z.string().describe("goal objective").optional(),
20
20
  token_budget: z.number().int().describe("token budget").optional(),
21
21
  });
@@ -86,7 +86,13 @@ export class GoalTool implements AgentTool<typeof goalSchema, GoalToolDetails> {
86
86
  response = buildGoalToolResponse(created.goal);
87
87
  } else if (params.op === "get") {
88
88
  const state = this.#session.getGoalModeState?.();
89
- response = buildGoalToolResponse(state?.enabled ? state.goal : null);
89
+ response = buildGoalToolResponse(state?.goal ?? null);
90
+ } else if (params.op === "resume") {
91
+ const resumed = await runtime.resumeGoal();
92
+ response = buildGoalToolResponse(resumed.goal);
93
+ } else if (params.op === "drop") {
94
+ const dropped = await runtime.dropGoal();
95
+ response = buildGoalToolResponse(dropped ?? null);
90
96
  } else {
91
97
  const completed = await runtime.completeGoalFromTool();
92
98
  response = buildGoalToolResponse(completed, { includeCompletionReport: true });
@@ -126,6 +132,10 @@ function describeOp(op: string | undefined): string {
126
132
  return "complete";
127
133
  case "get":
128
134
  return "check";
135
+ case "resume":
136
+ return "resume";
137
+ case "drop":
138
+ return "drop";
129
139
  default:
130
140
  return op ?? "?";
131
141
  }
@@ -30,7 +30,7 @@ export async function computeHashlineSectionDiff(
30
30
  const rawContent = await readHashlineFileText(Bun.file(absolutePath), absolutePath, section.path);
31
31
  const { text: content } = stripBom(rawContent);
32
32
  const normalized = normalizeToLF(content);
33
- const result = applyHashlineEdits(normalized, parseHashline(section.diff), options);
33
+ const result = applyHashlineEdits(normalized, parseHashline(section.diff, { path: section.path }), options);
34
34
  if (normalized === result.lines) return { error: `No changes would be made to ${section.path}.` };
35
35
  return generateDiffString(normalized, result.lines);
36
36
  } catch (err) {
@@ -106,7 +106,7 @@ async function preflightHashlineSection(options: ExecuteHashlineSingleOptions &
106
106
  const { session, path: sectionPath, diff } = options;
107
107
 
108
108
  const absolutePath = resolvePlanPath(session, sectionPath);
109
- const { edits } = parseHashlineWithWarnings(diff);
109
+ const { edits } = parseHashlineWithWarnings(diff, { path: sectionPath });
110
110
  enforcePlanModeWrite(session, sectionPath, { op: "update" });
111
111
 
112
112
  const source = await readHashlineFile(absolutePath, sectionPath);
@@ -139,7 +139,7 @@ async function executeHashlineSection(
139
139
  } = options;
140
140
 
141
141
  const absolutePath = resolvePlanPath(session, sourcePath);
142
- const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff);
142
+ const { edits, warnings: parseWarnings } = parseHashlineWithWarnings(diff, { path: sourcePath });
143
143
  enforcePlanModeWrite(session, sourcePath, { op: "update" });
144
144
 
145
145
  const source = await readHashlineFile(absolutePath, sourcePath);
@@ -74,22 +74,86 @@ export function cloneCursor(cursor: HashlineCursor): HashlineCursor {
74
74
  if (cursor.kind === "after_anchor") return { kind: "after_anchor", anchor: { ...cursor.anchor } };
75
75
  return cursor;
76
76
  }
77
- /** Returns true when every non-empty payload line starts with `${sep} ` (sep + one space). */
77
+ /**
78
+ * Returns true when every non-empty payload line looks like the `~ TEXT` readability-padding
79
+ * typo: exactly one leading space followed by a non-space character (or a bare single space).
80
+ *
81
+ * Indented file content (Python 4-space, YAML/JSON/Markdown 2-space, etc.) starts with two or
82
+ * more leading spaces, so this heuristic ignores legitimate indentation while still flagging
83
+ * the common `~ beta` mistake that silently corrupts file content with a stray space.
84
+ */
78
85
  function hasUniformSeparatorPadding(payload: string[]): boolean {
79
86
  let any = false;
80
87
  for (const text of payload) {
81
88
  if (text.length === 0) continue;
82
- if (!text.startsWith(" ")) return false;
89
+ if (text.charCodeAt(0) !== 0x20) return false;
90
+ // Two or more leading spaces is real indentation, not separator padding.
91
+ if (text.length > 1 && text.charCodeAt(1) === 0x20) return false;
83
92
  any = true;
84
93
  }
85
94
  return any;
86
95
  }
87
96
 
97
+ /**
98
+ * File extensions where leading single-space indentation is plausible legitimate file content
99
+ * (off-side-rule languages, structured-indent data formats, prose with continuation indent).
100
+ * For these we suppress the separator-padding warning entirely — the heuristic's false-positive
101
+ * cost on a real edit outweighs the rare chance it catches a `~ TEXT` typo.
102
+ */
103
+ const INDENT_SENSITIVE_EXTS: Record<string, true> = {
104
+ ".py": true,
105
+ ".pyi": true,
106
+ ".pyx": true,
107
+ ".pyw": true,
108
+ ".yml": true,
109
+ ".yaml": true,
110
+ ".md": true,
111
+ ".mdx": true,
112
+ ".markdown": true,
113
+ ".rst": true,
114
+ ".adoc": true,
115
+ ".asciidoc": true,
116
+ ".toml": true,
117
+ ".json": true,
118
+ ".jsonc": true,
119
+ ".json5": true,
120
+ ".ndjson": true,
121
+ ".jsonl": true,
122
+ ".tf": true,
123
+ ".tfvars": true,
124
+ ".hcl": true,
125
+ ".nix": true,
126
+ ".coffee": true,
127
+ ".litcoffee": true,
128
+ ".haml": true,
129
+ ".slim": true,
130
+ ".pug": true,
131
+ ".jade": true,
132
+ ".sass": true,
133
+ ".styl": true,
134
+ ".nim": true,
135
+ ".cr": true,
136
+ ".elm": true,
137
+ ".fs": true,
138
+ ".fsi": true,
139
+ ".fsx": true,
140
+ };
141
+
142
+ function isIndentationSensitivePath(path: string | undefined): boolean {
143
+ if (!path) return false;
144
+ const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
145
+ const dot = path.lastIndexOf(".");
146
+ if (dot <= slash) return false;
147
+ const ext = path.slice(dot).toLowerCase();
148
+ return INDENT_SENSITIVE_EXTS[ext] === true;
149
+ }
150
+
88
151
  function collectPayload(
89
152
  lines: string[],
90
153
  startIndex: number,
91
154
  opLineNum: number,
92
155
  requirePayload: boolean,
156
+ checkPadding: boolean,
93
157
  ): { payload: string[]; nextIndex: number; paddingWarning?: string } {
94
158
  const payload: string[] = [];
95
159
  let index = startIndex;
@@ -125,21 +189,32 @@ function collectPayload(
125
189
  if (payload.length === 0 && requirePayload) {
126
190
  throw new Error(`line ${opLineNum}: + and < operations require at least one ${HL_EDIT_SEP}TEXT payload line.`);
127
191
  }
128
- const paddingWarning = hasUniformSeparatorPadding(payload)
129
- ? `line ${opLineNum}: all payload lines start with "${HL_EDIT_SEP} " (separator + space). ` +
130
- `The space becomes file content. Remove it unless the target file requires leading spaces.`
131
- : undefined;
192
+ const paddingWarning =
193
+ checkPadding && hasUniformSeparatorPadding(payload)
194
+ ? `line ${opLineNum}: every payload line begins with exactly one space before non-space content, ` +
195
+ `which looks like a readability gap after "${HL_EDIT_SEP}". The space becomes file content. ` +
196
+ `Drop it unless the file genuinely uses a one-space indent.`
197
+ : undefined;
132
198
  return { payload, nextIndex: index, paddingWarning };
133
199
  }
134
200
 
135
- export function parseHashline(diff: string): HashlineEdit[] {
136
- return parseHashlineWithWarnings(diff).edits;
201
+ export function parseHashline(diff: string, opts: ParseHashlineOptions = {}): HashlineEdit[] {
202
+ return parseHashlineWithWarnings(diff, opts).edits;
203
+ }
204
+
205
+ export interface ParseHashlineOptions {
206
+ /** File path the diff targets. Used to suppress indent-sensitive false-positive warnings. */
207
+ path?: string;
137
208
  }
138
209
 
139
- export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]; warnings: string[] } {
210
+ export function parseHashlineWithWarnings(
211
+ diff: string,
212
+ opts: ParseHashlineOptions = {},
213
+ ): { edits: HashlineEdit[]; warnings: string[] } {
140
214
  const edits: HashlineEdit[] = [];
141
215
  const warnings: string[] = [];
142
216
  const lines = diff.split(/\r?\n/);
217
+ const checkPadding = !isIndentationSensitivePath(opts.path);
143
218
  let editIndex = 0;
144
219
 
145
220
  const pushInsert = (cursor: HashlineCursor, text: string, lineNum: number) => {
@@ -172,7 +247,7 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
172
247
  const insertBeforeMatch = INSERT_BEFORE_OP_RE.exec(line);
173
248
  if (insertBeforeMatch) {
174
249
  const cursor = parseInsertTarget(insertBeforeMatch[1], lineNum, "before");
175
- const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, true);
250
+ const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, true, checkPadding);
176
251
  if (paddingWarning) warnings.push(paddingWarning);
177
252
  for (const text of payload) pushInsert(cursor, text, lineNum);
178
253
  i = nextIndex;
@@ -182,7 +257,7 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
182
257
  const insertAfterMatch = INSERT_AFTER_OP_RE.exec(line);
183
258
  if (insertAfterMatch) {
184
259
  const cursor = parseInsertTarget(insertAfterMatch[1], lineNum, "after");
185
- const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, true);
260
+ const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, true, checkPadding);
186
261
  if (paddingWarning) warnings.push(paddingWarning);
187
262
  for (const text of payload) pushInsert(cursor, text, lineNum);
188
263
  i = nextIndex;
@@ -201,7 +276,7 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
201
276
  const replaceMatch = REPLACE_OP_RE.exec(line);
202
277
  if (replaceMatch) {
203
278
  const range = parseRange(replaceMatch[1], lineNum);
204
- const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, false);
279
+ const { payload, nextIndex, paddingWarning } = collectPayload(lines, i + 1, lineNum, false, checkPadding);
205
280
  if (paddingWarning) warnings.push(paddingWarning);
206
281
  // `= A..B` with no payload blanks the range to a single empty line.
207
282
  const replacement = payload.length === 0 ? [""] : payload;
@@ -14,7 +14,7 @@ const MEMORY_NAMESPACE = "root";
14
14
  * Each session has its own cwd (possibly a worktree), so subagents and main
15
15
  * may see different roots.
16
16
  */
17
- function memoryRootsFromRegistry(): string[] {
17
+ export function memoryRootsFromRegistry(): string[] {
18
18
  const agentDir = getAgentDir();
19
19
  const roots: string[] = [];
20
20
  for (const ref of AgentRegistry.global().list()) {
@@ -98,6 +98,7 @@ import {
98
98
  } from "./loop-limit";
99
99
  import { OAuthManualInputManager } from "./oauth-manual-input";
100
100
  import { SessionObserverRegistry } from "./session-observer-registry";
101
+ import { type ShimmerPalette, shimmerSegments, shimmerText } from "./theme/shimmer";
101
102
  import type { Theme } from "./theme/theme";
102
103
  import {
103
104
  getEditorTheme,
@@ -110,6 +111,20 @@ import {
110
111
  import type { CompactionQueuedMessage, InteractiveModeContext, SubmittedUserInput, TodoItem, TodoPhase } from "./types";
111
112
  import { UiHelpers } from "./utils/ui-helpers";
112
113
 
114
+ const WORKING_INTERRUPT_HINT = " (esc to interrupt)";
115
+
116
+ const HINT_SHIMMER_PALETTE: ShimmerPalette = {
117
+ low: "dim",
118
+ mid: "muted",
119
+ high: "borderAccent",
120
+ };
121
+
122
+ function renderWorkingMessage(message: string): string {
123
+ if (!message.endsWith(WORKING_INTERRUPT_HINT)) return shimmerText(message, theme);
124
+ const header = message.slice(0, -WORKING_INTERRUPT_HINT.length);
125
+ return shimmerSegments([{ text: header }, { text: WORKING_INTERRUPT_HINT, palette: HINT_SHIMMER_PALETTE }], theme);
126
+ }
127
+
113
128
  const EDITOR_MAX_HEIGHT_MIN = 6;
114
129
  const EDITOR_MAX_HEIGHT_MAX = 18;
115
130
  const EDITOR_RESERVED_ROWS = 12;
@@ -1063,6 +1078,12 @@ export class InteractiveMode implements InteractiveModeContext {
1063
1078
  return;
1064
1079
  }
1065
1080
  if (event.type === "goal_updated") {
1081
+ // Handle drop before clearing goalModeEnabled so #exitGoalMode can
1082
+ // still restore the previous tool set while the flag is true.
1083
+ if (event.state?.goal?.status === "dropped") {
1084
+ await this.#exitGoalMode({ reason: "dropped", silent: true });
1085
+ return;
1086
+ }
1066
1087
  this.goalModeEnabled = event.state?.enabled === true;
1067
1088
  this.goalModePaused = event.state?.enabled !== true && event.state?.goal?.status === "paused";
1068
1089
  if (!event.state?.enabled) {
@@ -1150,6 +1171,13 @@ export class InteractiveMode implements InteractiveModeContext {
1150
1171
  const restored = await this.session.goalRuntime.onThreadResumed();
1151
1172
  this.goalModeEnabled = restored?.enabled === true;
1152
1173
  this.goalModePaused = restored?.enabled !== true && restored?.goal.status === "paused";
1174
+ // sdk.ts excludes "goal" from the initial active tool set unconditionally.
1175
+ // Re-add it now so the agent can call resume, complete, or drop on this goal.
1176
+ if (restored?.goal) {
1177
+ const previousTools = this.session.getActiveToolNames().filter(name => name !== "goal");
1178
+ this.#goalModePreviousTools = previousTools;
1179
+ await this.session.setActiveToolsByName([...new Set([...previousTools, "goal"])]);
1180
+ }
1153
1181
  this.#updateGoalModeStatus();
1154
1182
  return;
1155
1183
  }
@@ -2167,7 +2195,7 @@ export class InteractiveMode implements InteractiveModeContext {
2167
2195
  this.loadingAnimation = new Loader(
2168
2196
  this.ui,
2169
2197
  spinner => theme.fg("accent", spinner),
2170
- text => theme.fg("muted", text),
2198
+ renderWorkingMessage,
2171
2199
  this.#defaultWorkingMessage,
2172
2200
  getSymbolTheme().spinnerFrames,
2173
2201
  );
@@ -0,0 +1,79 @@
1
+ import type { Theme, ThemeColor } from "./theme";
2
+
3
+ const SHIMMER_PADDING = 10;
4
+ const SHIMMER_SWEEP_MS = 2000;
5
+ const SHIMMER_BAND_HALF_WIDTH = 5;
6
+
7
+ type ShimmerTheme = Pick<Theme, "bold" | "fg">;
8
+
9
+ /** Three-tier color stack a shimmer character cycles through as the band sweeps. */
10
+ export interface ShimmerPalette {
11
+ /** Color for chars outside / at the edge of the band (intensity < 0.2). */
12
+ low: ThemeColor;
13
+ /** Color for chars approaching the crest (0.2 <= intensity < 0.6). */
14
+ mid: ThemeColor;
15
+ /** Color at the band's crest (intensity >= 0.6). */
16
+ high: ThemeColor;
17
+ /** Whether to bold the crest tier. Default `false`. */
18
+ bold?: boolean;
19
+ }
20
+
21
+ /** One run of text that shares a palette inside a larger shimmer sweep. */
22
+ export interface ShimmerSegment {
23
+ text: string;
24
+ palette?: ShimmerPalette;
25
+ }
26
+
27
+ export const DEFAULT_SHIMMER_PALETTE: ShimmerPalette = {
28
+ low: "dim",
29
+ mid: "muted",
30
+ high: "accent",
31
+ bold: true,
32
+ };
33
+
34
+ function shimmerIntensity(index: number, length: number): number {
35
+ const period = length + SHIMMER_PADDING * 2;
36
+ const pos = Math.floor(((Date.now() % SHIMMER_SWEEP_MS) / SHIMMER_SWEEP_MS) * period);
37
+ const dist = Math.abs(index + SHIMMER_PADDING - pos);
38
+ if (dist > SHIMMER_BAND_HALF_WIDTH) return 0;
39
+
40
+ const x = Math.PI * (dist / SHIMMER_BAND_HALF_WIDTH);
41
+ return 0.5 * (1 + Math.cos(x));
42
+ }
43
+
44
+ function styleShimmerChar(ch: string, intensity: number, theme: ShimmerTheme, palette: ShimmerPalette): string {
45
+ if (intensity < 0.2) return theme.fg(palette.low, ch);
46
+ if (intensity < 0.6) return theme.fg(palette.mid, ch);
47
+ const styled = theme.fg(palette.high, ch);
48
+ return palette.bold ? theme.bold(styled) : styled;
49
+ }
50
+
51
+ /**
52
+ * Apply a shimmer sweep across one or more segments, treating them as a single
53
+ * continuous string for band positioning. Each segment can supply its own
54
+ * palette so the gradient stays in lockstep while the colors differ.
55
+ */
56
+ export function shimmerSegments(segments: readonly ShimmerSegment[], theme: ShimmerTheme): string {
57
+ let total = 0;
58
+ const expanded: Array<{ chars: string[]; palette: ShimmerPalette }> = [];
59
+ for (const seg of segments) {
60
+ const chars = [...seg.text];
61
+ total += chars.length;
62
+ expanded.push({ chars, palette: seg.palette ?? DEFAULT_SHIMMER_PALETTE });
63
+ }
64
+ if (total === 0) return "";
65
+
66
+ const out: string[] = [];
67
+ let index = 0;
68
+ for (const { chars, palette } of expanded) {
69
+ for (const ch of chars) {
70
+ out.push(styleShimmerChar(ch, shimmerIntensity(index, total), theme, palette));
71
+ index++;
72
+ }
73
+ }
74
+ return out.join("");
75
+ }
76
+
77
+ export function shimmerText(text: string, theme: ShimmerTheme, palette?: ShimmerPalette): string {
78
+ return shimmerSegments([{ text, palette }], theme);
79
+ }
@@ -1,13 +1,18 @@
1
1
  Manage the active goal-mode objective.
2
2
 
3
3
  Use a single `op` field:
4
- - `create` starts a goal. Requires `objective`; optional `token_budget` must be positive. Use only when no goal exists.
5
- - `get` returns the current goal and remaining token budget.
4
+ - `create` starts a goal. Requires `objective`; optional `token_budget` must be positive. Use only when no goal exists and no goal is paused.
5
+ - `get` returns the current goal (active or paused) and remaining token budget.
6
+ - `resume` re-activates a paused goal so work can continue.
6
7
  - `complete` marks the goal complete after you have verified every deliverable against current evidence.
8
+ - `drop` discards the current goal without completing it.
7
9
 
8
10
  Examples:
9
11
  - `goal({"op":"create","objective":"Implement feature X","token_budget":50000})`
10
12
  - `goal({"op":"get"})`
13
+ - `goal({"op":"resume"})`
11
14
  - `goal({"op":"complete"})`
15
+ - `goal({"op":"drop"})`
12
16
 
13
17
  Do not call `complete` because a budget is low or a turn is ending. Call it only when the goal is actually done and verified.
18
+ If `get` shows a paused goal, call `resume` before continuing work on it.
@@ -76,6 +76,7 @@ import {
76
76
  } from "@oh-my-pi/pi-ai";
77
77
  import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
78
78
  import {
79
+ extractRetryHint,
79
80
  getAgentDbPath,
80
81
  isEnoent,
81
82
  isUnexpectedSocketCloseMessage,
@@ -440,11 +441,6 @@ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): strin
440
441
  return `${selector.provider}/${selector.id}`;
441
442
  }
442
443
 
443
- /** Composite key for auto-clear timers, keyed by phase name + task content. */
444
- function todoClearKey(phaseName: string, taskContent: string): string {
445
- return `${phaseName}\u0000${taskContent}`;
446
- }
447
-
448
444
  const IRC_REPLY_MAX_BYTES = 4096;
449
445
 
450
446
  /**
@@ -796,7 +792,6 @@ export class AgentSession {
796
792
  // Todo completion reminder state
797
793
  #todoReminderCount = 0;
798
794
  #todoPhases: TodoPhase[] = [];
799
- #todoClearTimers = new Map<string, Timer>();
800
795
  #toolChoiceQueue = new ToolChoiceQueue();
801
796
 
802
797
  // Bash execution state
@@ -2734,7 +2729,6 @@ export class AgentSession {
2734
2729
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
2735
2730
  }
2736
2731
  await this.#cancelPostPromptTasks();
2737
- this.#clearTodoClearTimers();
2738
2732
  // Cancel jobs this agent registered so a subagent's teardown doesn't
2739
2733
  // leak its background bash/task work into the parent's manager. Only
2740
2734
  // the session that owns the manager goes on to dispose it (which itself
@@ -4628,13 +4622,12 @@ export class AgentSession {
4628
4622
 
4629
4623
  setTodoPhases(phases: TodoPhase[]): void {
4630
4624
  this.#todoPhases = this.#cloneTodoPhases(phases);
4631
- this.#scheduleTodoAutoClear(phases);
4632
4625
  }
4633
4626
 
4634
4627
  #syncTodoPhasesFromBranch(): void {
4635
4628
  const phases = getLatestTodoPhasesFromEntries(this.sessionManager.getBranch());
4636
4629
  // Strip completed/abandoned tasks — they were done in a previous run,
4637
- // so the auto-clear grace period has already elapsed.
4630
+ // so they have no bearing on progress tracking for the new turn.
4638
4631
  for (const phase of phases) {
4639
4632
  phase.tasks = phase.tasks.filter(t => t.status !== "completed" && t.status !== "abandoned");
4640
4633
  }
@@ -4652,72 +4645,11 @@ export class AgentSession {
4652
4645
  }));
4653
4646
  }
4654
4647
 
4655
- /** Schedule auto-removal of completed/abandoned tasks after a delay. */
4656
- #scheduleTodoAutoClear(phases: TodoPhase[]): void {
4657
- // Default bumped from 60s to 30 min: the prior 60s splice mutated canonical
4658
- // state mid-turn, so the model observed phase totals shrinking ("6 → 5")
4659
- // between tool calls. Surviving the turn matches user expectations; a
4660
- // render-time filter in the UI consumer would be cleaner but lives in a
4661
- // different package and is out of scope for this fix.
4662
- const delaySec = this.settings.get("tasks.todoClearDelay") ?? 1800;
4663
- if (delaySec < 0) return; // "Never" — no auto-clear
4664
- const delayMs = delaySec * 1000;
4665
- const doneKeys = new Set<string>();
4666
- for (const phase of phases) {
4667
- for (const task of phase.tasks) {
4668
- if (task.status === "completed" || task.status === "abandoned") {
4669
- doneKeys.add(todoClearKey(phase.name, task.content));
4670
- }
4671
- }
4672
- }
4673
-
4674
- // Cancel timers for tasks that are no longer done (e.g. status was reverted)
4675
- for (const [key, timer] of this.#todoClearTimers) {
4676
- if (!doneKeys.has(key)) {
4677
- clearTimeout(timer);
4678
- this.#todoClearTimers.delete(key);
4679
- }
4680
- }
4681
-
4682
- // Schedule new timers for newly-done tasks
4683
- for (const key of doneKeys) {
4684
- if (this.#todoClearTimers.has(key)) continue;
4685
- if (delayMs === 0) {
4686
- // Instant — run synchronously on next microtask to batch removals
4687
- const timer = setTimeout(() => this.#runTodoAutoClear(key), 0);
4688
- this.#todoClearTimers.set(key, timer);
4689
- } else {
4690
- const timer = setTimeout(() => this.#runTodoAutoClear(key), delayMs);
4691
- this.#todoClearTimers.set(key, timer);
4692
- }
4693
- }
4694
- }
4695
-
4696
- /** Remove a single completed task and notify the UI. */
4697
- #runTodoAutoClear(key: string): void {
4698
- this.#todoClearTimers.delete(key);
4699
- let removed = false;
4700
- for (const phase of this.#todoPhases) {
4701
- const idx = phase.tasks.findIndex(t => todoClearKey(phase.name, t.content) === key);
4702
- if (idx !== -1 && (phase.tasks[idx].status === "completed" || phase.tasks[idx].status === "abandoned")) {
4703
- phase.tasks.splice(idx, 1);
4704
- removed = true;
4705
- break;
4706
- }
4707
- }
4708
- if (!removed) return;
4709
-
4710
- // Remove empty phases
4711
- this.#todoPhases = this.#todoPhases.filter(p => p.tasks.length > 0);
4712
- this.#emit({ type: "todo_auto_clear" });
4713
- }
4714
-
4715
- #clearTodoClearTimers(): void {
4716
- for (const timer of this.#todoClearTimers.values()) {
4717
- clearTimeout(timer);
4718
- }
4719
- this.#todoClearTimers.clear();
4720
- }
4648
+ // Auto-clear of completed/abandoned tasks was removed: the timer-driven
4649
+ // splice mutated canonical `#todoPhases` between tool calls, so the model
4650
+ // observed phase totals shrinking ("5 4") after marking tasks done. The
4651
+ // `tasks.todoClearDelay` setting is now inert; completed tasks survive
4652
+ // until the next explicit `todo_write` call removes them via `rm`/`drop`.
4721
4653
 
4722
4654
  /**
4723
4655
  * Abort current operation and wait for agent to become idle.
@@ -6240,6 +6172,12 @@ export class AgentSession {
6240
6172
  };
6241
6173
 
6242
6174
  const currentModel = this.model;
6175
+ // Prefer the active session's model: it's what the user is actively using,
6176
+ // and routing compaction to a different provider (e.g. an OpenAI default
6177
+ // model while the chat is on Anthropic) changes provider-specific behavior
6178
+ // like remote compaction endpoints. Role-based candidates only kick in
6179
+ // as auth fallbacks when the current model has no usable credentials.
6180
+ addCandidate(currentModel);
6243
6181
  for (const role of MODEL_ROLE_IDS) {
6244
6182
  addCandidate(this.#resolveRoleModelFull(role, availableModels, currentModel).model);
6245
6183
  }
@@ -7012,6 +6950,11 @@ export class AgentSession {
7012
6950
  }
7013
6951
  }
7014
6952
 
6953
+ const retryHintMs = extractRetryHint(undefined, errorMessage);
6954
+ if (retryHintMs !== undefined) {
6955
+ return retryHintMs;
6956
+ }
6957
+
7015
6958
  const resetMsMatch = /x-ratelimit-reset-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
7016
6959
  if (resetMsMatch) {
7017
6960
  const resetMs = Number(resetMsMatch[1]);
@@ -1,3 +1,6 @@
1
+ import { shimmerText } from "../../modes/theme/shimmer";
2
+ import { theme as currentTheme, type Theme } from "../../modes/theme/theme";
3
+
1
4
  /** Format a millisecond duration as a coarse-grained human label. */
2
5
  export function formatDuration(ms: number): string {
3
6
  const seconds = Math.max(0, Math.round(ms / 1000));
@@ -10,14 +13,31 @@ export function formatDuration(ms: number): string {
10
13
  return `${days}d`;
11
14
  }
12
15
 
16
+ type ProgressBarTheme = Pick<Theme, "bold" | "fg">;
17
+
18
+ const unstyledProgressBarTheme: ProgressBarTheme = {
19
+ fg(_color, text) {
20
+ return text;
21
+ },
22
+ bold(text) {
23
+ return text;
24
+ },
25
+ };
26
+
27
+ function resolveProgressBarTheme(uiTheme: ProgressBarTheme | undefined): ProgressBarTheme {
28
+ return uiTheme ?? currentTheme ?? unstyledProgressBarTheme;
29
+ }
30
+
13
31
  /**
14
32
  * Render an ASCII progress bar with a trailing percent label.
15
33
  * `fraction` is clamped to `[0, 1]`. `undefined` renders a dotted placeholder.
16
34
  */
17
- export function renderAsciiBar(fraction: number | undefined, width = 24): string {
18
- if (fraction === undefined) return `[${"·".repeat(width)}]`;
35
+ export function renderAsciiBar(fraction: number | undefined, width = 24, uiTheme?: ProgressBarTheme): string {
36
+ const progressBarTheme = resolveProgressBarTheme(uiTheme);
37
+ if (fraction === undefined) return `[${shimmerText("·".repeat(width), progressBarTheme)}]`;
19
38
  const clamped = Math.min(Math.max(fraction, 0), 1);
20
39
  const filled = Math.round(clamped * width);
21
40
  const pct = Math.round(clamped * 100);
22
- return `[${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}] ${pct}%`;
41
+ const bar = `${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}`;
42
+ return `[${shimmerText(bar, progressBarTheme)}] ${pct}%`;
23
43
  }