@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.
- package/CHANGELOG.md +36 -1
- package/dist/types/config/settings-schema.d.ts +10 -0
- package/dist/types/eval/py/kernel.d.ts +6 -0
- package/dist/types/goals/state.d.ts +1 -1
- package/dist/types/goals/tools/goal-tool.d.ts +4 -0
- package/dist/types/hashline/parser.d.ts +6 -2
- package/dist/types/internal-urls/memory-protocol.d.ts +6 -0
- package/dist/types/modes/theme/shimmer.d.ts +27 -0
- package/dist/types/slash-commands/helpers/format.d.ts +4 -1
- package/dist/types/tools/ast-edit.d.ts +3 -0
- package/dist/types/tools/ast-grep.d.ts +3 -0
- package/dist/types/tools/browser/launch.d.ts +2 -0
- package/dist/types/tools/find.d.ts +3 -0
- package/dist/types/tools/search.d.ts +3 -0
- package/dist/types/tui/file-list.d.ts +6 -0
- package/dist/types/tui/hyperlink.d.ts +42 -0
- package/dist/types/tui/index.d.ts +1 -0
- package/dist/types/web/search/providers/utils.d.ts +2 -1
- package/package.json +7 -7
- package/src/config/settings-schema.ts +12 -0
- package/src/config/settings.ts +28 -5
- package/src/discovery/builtin.ts +30 -0
- package/src/edit/renderer.ts +5 -3
- package/src/eval/py/executor.ts +12 -1
- package/src/eval/py/kernel.ts +24 -8
- package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
- package/src/goals/runtime.ts +9 -3
- package/src/goals/state.ts +1 -1
- package/src/goals/tools/goal-tool.ts +12 -2
- package/src/hashline/diff.ts +1 -1
- package/src/hashline/execute.ts +2 -2
- package/src/hashline/parser.ts +87 -12
- package/src/internal-urls/memory-protocol.ts +1 -1
- package/src/modes/interactive-mode.ts +29 -1
- package/src/modes/theme/shimmer.ts +79 -0
- package/src/prompts/tools/goal.md +7 -2
- package/src/session/agent-session.ts +18 -75
- package/src/slash-commands/helpers/format.ts +23 -3
- package/src/task/executor.ts +115 -19
- package/src/tools/ast-edit.ts +39 -6
- package/src/tools/ast-grep.ts +38 -6
- package/src/tools/browser/launch.ts +63 -51
- package/src/tools/find.ts +13 -2
- package/src/tools/read.ts +46 -6
- package/src/tools/search.ts +447 -265
- package/src/tui/file-list.ts +10 -2
- package/src/tui/hyperlink.ts +126 -0
- package/src/tui/index.ts +1 -0
- package/src/web/search/index.ts +13 -9
- package/src/web/search/providers/anthropic.ts +3 -1
- package/src/web/search/providers/brave.ts +3 -1
- package/src/web/search/providers/codex.ts +3 -1
- package/src/web/search/providers/exa.ts +3 -1
- package/src/web/search/providers/gemini.ts +3 -1
- package/src/web/search/providers/jina.ts +3 -1
- package/src/web/search/providers/kagi.ts +5 -1
- package/src/web/search/providers/kimi.ts +3 -1
- package/src/web/search/providers/parallel.ts +5 -1
- package/src/web/search/providers/perplexity.ts +5 -1
- package/src/web/search/providers/searxng.ts +3 -1
- package/src/web/search/providers/synthetic.ts +3 -1
- package/src/web/search/providers/tavily.ts +3 -1
- package/src/web/search/providers/utils.ts +33 -1
- 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,
|
|
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);
|
package/src/goals/runtime.ts
CHANGED
|
@@ -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?.
|
|
463
|
-
throw new Error("cannot complete goal because goal
|
|
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";
|
package/src/goals/state.ts
CHANGED
|
@@ -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?.
|
|
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
|
}
|
package/src/hashline/diff.ts
CHANGED
|
@@ -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) {
|
package/src/hashline/execute.ts
CHANGED
|
@@ -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);
|
package/src/hashline/parser.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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 (
|
|
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 =
|
|
129
|
-
|
|
130
|
-
`
|
|
131
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
+
const bar = `${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}`;
|
|
42
|
+
return `[${shimmerText(bar, progressBarTheme)}] ${pct}%`;
|
|
23
43
|
}
|