@oh-my-pi/pi-coding-agent 15.1.8 → 15.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -1
- package/dist/types/cli/update-cli.d.ts +18 -0
- 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/main.d.ts +25 -1
- 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/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/utils/tool-choice.d.ts +2 -1
- package/dist/types/web/search/providers/utils.d.ts +27 -1
- package/package.json +7 -7
- package/src/cli/update-cli.ts +78 -36
- package/src/config/model-registry.ts +23 -12
- package/src/config/settings-schema.ts +12 -0
- package/src/config/settings.ts +28 -5
- 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/main.ts +13 -2
- package/src/modes/interactive-mode.ts +29 -1
- package/src/modes/theme/shimmer.ts +79 -0
- package/src/prompts/agents/oracle.md +15 -16
- package/src/prompts/tools/goal.md +7 -2
- package/src/session/agent-session.ts +12 -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/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/utils/tool-choice.ts +7 -7
- package/src/web/kagi.ts +2 -2
- package/src/web/parallel.ts +3 -3
- package/src/web/search/index.ts +20 -9
- package/src/web/search/providers/anthropic.ts +4 -2
- package/src/web/search/providers/brave.ts +4 -2
- package/src/web/search/providers/codex.ts +4 -1
- package/src/web/search/providers/exa.ts +4 -1
- package/src/web/search/providers/gemini.ts +4 -1
- package/src/web/search/providers/jina.ts +4 -2
- package/src/web/search/providers/kagi.ts +5 -1
- package/src/web/search/providers/kimi.ts +4 -2
- package/src/web/search/providers/parallel.ts +5 -1
- package/src/web/search/providers/perplexity.ts +7 -2
- package/src/web/search/providers/searxng.ts +4 -1
- package/src/web/search/providers/synthetic.ts +4 -2
- package/src/web/search/providers/tavily.ts +4 -2
- package/src/web/search/providers/utils.ts +63 -1
- package/src/web/search/providers/zai.ts +4 -2
|
@@ -1017,7 +1017,8 @@ export class ModelRegistry {
|
|
|
1017
1017
|
}
|
|
1018
1018
|
|
|
1019
1019
|
#addImplicitDiscoverableProviders(configuredProviders: Set<string>): void {
|
|
1020
|
-
|
|
1020
|
+
const disabledProviders = getDisabledProviderIdsFromSettings();
|
|
1021
|
+
if (!configuredProviders.has("ollama") && !disabledProviders.has("ollama")) {
|
|
1021
1022
|
this.#discoverableProviders.push({
|
|
1022
1023
|
provider: "ollama",
|
|
1023
1024
|
api: "openai-responses",
|
|
@@ -1027,7 +1028,7 @@ export class ModelRegistry {
|
|
|
1027
1028
|
});
|
|
1028
1029
|
this.#keylessProviders.add("ollama");
|
|
1029
1030
|
}
|
|
1030
|
-
if (!configuredProviders.has("llama.cpp")) {
|
|
1031
|
+
if (!configuredProviders.has("llama.cpp") && !disabledProviders.has("llama.cpp")) {
|
|
1031
1032
|
this.#discoverableProviders.push({
|
|
1032
1033
|
provider: "llama.cpp",
|
|
1033
1034
|
api: "openai-responses",
|
|
@@ -1040,7 +1041,7 @@ export class ModelRegistry {
|
|
|
1040
1041
|
this.#keylessProviders.add("llama.cpp");
|
|
1041
1042
|
}
|
|
1042
1043
|
}
|
|
1043
|
-
if (!configuredProviders.has("lm-studio")) {
|
|
1044
|
+
if (!configuredProviders.has("lm-studio") && !disabledProviders.has("lm-studio")) {
|
|
1044
1045
|
this.#discoverableProviders.push({
|
|
1045
1046
|
provider: "lm-studio",
|
|
1046
1047
|
api: "openai-completions",
|
|
@@ -1160,9 +1161,12 @@ export class ModelRegistry {
|
|
|
1160
1161
|
strategy: ModelRefreshStrategy,
|
|
1161
1162
|
providerFilter?: ReadonlySet<string>,
|
|
1162
1163
|
): Promise<void> {
|
|
1163
|
-
const
|
|
1164
|
-
|
|
1165
|
-
|
|
1164
|
+
const disabledProviders = getDisabledProviderIdsFromSettings();
|
|
1165
|
+
const selectedDiscoverableProviders = (
|
|
1166
|
+
providerFilter
|
|
1167
|
+
? this.#discoverableProviders.filter(provider => providerFilter.has(provider.provider))
|
|
1168
|
+
: this.#discoverableProviders
|
|
1169
|
+
).filter(provider => !disabledProviders.has(provider.provider));
|
|
1166
1170
|
const configuredDiscoveriesPromise =
|
|
1167
1171
|
selectedDiscoverableProviders.length === 0
|
|
1168
1172
|
? Promise.resolve<Model<Api>[]>([])
|
|
@@ -1366,17 +1370,24 @@ export class ModelRegistry {
|
|
|
1366
1370
|
},
|
|
1367
1371
|
},
|
|
1368
1372
|
];
|
|
1373
|
+
const disabledProviders = getDisabledProviderIdsFromSettings();
|
|
1374
|
+
const standardProviderDescriptors = PROVIDER_DESCRIPTORS.filter(
|
|
1375
|
+
descriptor => !disabledProviders.has(descriptor.providerId),
|
|
1376
|
+
);
|
|
1377
|
+
const enabledSpecialProviderDescriptors = specialProviderDescriptors.filter(
|
|
1378
|
+
descriptor => !disabledProviders.has(descriptor.providerId),
|
|
1379
|
+
);
|
|
1369
1380
|
// Use peekApiKey to avoid OAuth token refresh during discovery.
|
|
1370
1381
|
// The token is only needed if the dynamic fetch fires (cache miss),
|
|
1371
1382
|
// and failures there are handled gracefully.
|
|
1372
1383
|
const peekKey = (descriptor: { providerId: string }) => this.#peekApiKeyForProvider(descriptor.providerId);
|
|
1373
1384
|
const [standardProviderKeys, specialKeys] = await Promise.all([
|
|
1374
|
-
Promise.all(
|
|
1375
|
-
Promise.all(
|
|
1385
|
+
Promise.all(standardProviderDescriptors.map(peekKey)),
|
|
1386
|
+
Promise.all(enabledSpecialProviderDescriptors.map(peekKey)),
|
|
1376
1387
|
]);
|
|
1377
1388
|
const options: ModelManagerOptions<Api>[] = [];
|
|
1378
|
-
for (let i = 0; i <
|
|
1379
|
-
const descriptor =
|
|
1389
|
+
for (let i = 0; i < standardProviderDescriptors.length; i++) {
|
|
1390
|
+
const descriptor = standardProviderDescriptors[i];
|
|
1380
1391
|
const apiKey = standardProviderKeys[i];
|
|
1381
1392
|
if (isAuthenticated(apiKey) || descriptor.allowUnauthenticated) {
|
|
1382
1393
|
options.push(
|
|
@@ -1388,8 +1399,8 @@ export class ModelRegistry {
|
|
|
1388
1399
|
}
|
|
1389
1400
|
}
|
|
1390
1401
|
|
|
1391
|
-
for (let i = 0; i <
|
|
1392
|
-
const descriptor =
|
|
1402
|
+
for (let i = 0; i < enabledSpecialProviderDescriptors.length; i++) {
|
|
1403
|
+
const descriptor = enabledSpecialProviderDescriptors[i];
|
|
1393
1404
|
const key = descriptor.resolveKey(specialKeys[i]);
|
|
1394
1405
|
if (!isAuthenticated(key)) {
|
|
1395
1406
|
continue;
|
|
@@ -583,6 +583,18 @@ export const SETTINGS_SCHEMA = {
|
|
|
583
583
|
description:
|
|
584
584
|
"Maximum height in terminal rows for inline images (default 20). Set to 0 to use only the viewport-based limit (60% of terminal height).",
|
|
585
585
|
},
|
|
586
|
+
|
|
587
|
+
"tui.hyperlinks": {
|
|
588
|
+
type: "enum",
|
|
589
|
+
values: ["off", "auto", "always"] as const,
|
|
590
|
+
default: "auto",
|
|
591
|
+
ui: {
|
|
592
|
+
tab: "appearance",
|
|
593
|
+
label: "Terminal Hyperlinks",
|
|
594
|
+
description:
|
|
595
|
+
"Wrap file paths in OSC 8 hyperlinks for terminal-native click-to-open (auto: detect support; off: never; always: unconditional)",
|
|
596
|
+
},
|
|
597
|
+
},
|
|
586
598
|
// Display rendering
|
|
587
599
|
"display.tabWidth": {
|
|
588
600
|
type: "number",
|
package/src/config/settings.ts
CHANGED
|
@@ -129,6 +129,18 @@ function stringArrayFromUnknown(value: unknown): string[] {
|
|
|
129
129
|
return [];
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
function shallowStringRecord(value: unknown): Record<string, string> {
|
|
133
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
134
|
+
|
|
135
|
+
const result: Record<string, string> = {};
|
|
136
|
+
for (const [key, item] of Object.entries(value)) {
|
|
137
|
+
if (typeof item === "string") {
|
|
138
|
+
result[key] = item;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
132
144
|
function resolvePathScopedStringArray(settingPath: SettingPath, value: unknown, cwd: string): string[] | undefined {
|
|
133
145
|
if (!PATH_SCOPED_ARRAY_SETTINGS.has(settingPath) || !Array.isArray(value)) return undefined;
|
|
134
146
|
|
|
@@ -424,8 +436,19 @@ export class Settings {
|
|
|
424
436
|
* Set a model role (helper for modelRoles record).
|
|
425
437
|
*/
|
|
426
438
|
setModelRole(role: ModelRole | string, modelId: string): void {
|
|
427
|
-
const current = this
|
|
439
|
+
const current = shallowStringRecord(getByPath(this.#global, ["modelRoles"]));
|
|
440
|
+
const runtimeOverrides = getByPath(this.#overrides, ["modelRoles"]);
|
|
441
|
+
const updateRuntimeOverride =
|
|
442
|
+
!!runtimeOverrides &&
|
|
443
|
+
typeof runtimeOverrides === "object" &&
|
|
444
|
+
!Array.isArray(runtimeOverrides) &&
|
|
445
|
+
Object.hasOwn(runtimeOverrides, role);
|
|
446
|
+
|
|
428
447
|
this.set("modelRoles", { ...current, [role]: modelId });
|
|
448
|
+
|
|
449
|
+
if (updateRuntimeOverride) {
|
|
450
|
+
this.override("modelRoles", { ...shallowStringRecord(runtimeOverrides), [role]: modelId });
|
|
451
|
+
}
|
|
429
452
|
}
|
|
430
453
|
|
|
431
454
|
/**
|
|
@@ -440,20 +463,20 @@ export class Settings {
|
|
|
440
463
|
* Get all model roles (helper for modelRoles record).
|
|
441
464
|
*/
|
|
442
465
|
getModelRoles(): ReadOnlyDict<string> {
|
|
443
|
-
return this.get("modelRoles");
|
|
466
|
+
return { ...this.get("modelRoles") };
|
|
444
467
|
}
|
|
445
468
|
|
|
446
469
|
/*
|
|
447
470
|
* Override model roles (helper for modelRoles record).
|
|
448
471
|
*/
|
|
449
472
|
overrideModelRoles(roles: ReadOnlyDict<string>): void {
|
|
450
|
-
const
|
|
473
|
+
const next = shallowStringRecord(getByPath(this.#overrides, ["modelRoles"]));
|
|
451
474
|
for (const [role, modelId] of Object.entries(roles)) {
|
|
452
475
|
if (modelId) {
|
|
453
|
-
|
|
476
|
+
next[role] = modelId;
|
|
454
477
|
}
|
|
455
478
|
}
|
|
456
|
-
this.override("modelRoles",
|
|
479
|
+
this.override("modelRoles", next);
|
|
457
480
|
}
|
|
458
481
|
|
|
459
482
|
/**
|
package/src/edit/renderer.ts
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
truncateDiffByHunk,
|
|
26
26
|
} from "../tools/render-utils";
|
|
27
27
|
import { type VimRenderArgs, vimToolRenderer } from "../tools/vim";
|
|
28
|
-
import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
|
|
28
|
+
import { fileHyperlink, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
|
|
29
29
|
import type { EditMode } from "../utils/edit-mode";
|
|
30
30
|
import type { VimToolDetails } from "../vim/types";
|
|
31
31
|
import type { DiffError, DiffResult } from "./diff";
|
|
@@ -219,14 +219,16 @@ function formatEditPathDisplay(
|
|
|
219
219
|
uiTheme: Theme,
|
|
220
220
|
options?: { rename?: string; firstChangedLine?: number },
|
|
221
221
|
): string {
|
|
222
|
-
let pathDisplay = rawPath
|
|
222
|
+
let pathDisplay = rawPath
|
|
223
|
+
? fileHyperlink(rawPath, uiTheme.fg("accent", shortenPath(rawPath)))
|
|
224
|
+
: uiTheme.fg("toolOutput", "…");
|
|
223
225
|
|
|
224
226
|
if (options?.firstChangedLine) {
|
|
225
227
|
pathDisplay += uiTheme.fg("warning", `:${options.firstChangedLine}`);
|
|
226
228
|
}
|
|
227
229
|
|
|
228
230
|
if (options?.rename) {
|
|
229
|
-
pathDisplay += ` ${uiTheme.fg("dim", "→")} ${uiTheme.fg("accent", shortenPath(options.rename))}`;
|
|
231
|
+
pathDisplay += ` ${uiTheme.fg("dim", "→")} ${fileHyperlink(options.rename, uiTheme.fg("accent", shortenPath(options.rename)))}`;
|
|
230
232
|
}
|
|
231
233
|
|
|
232
234
|
return pathDisplay;
|
package/src/eval/py/executor.ts
CHANGED
|
@@ -209,6 +209,15 @@ function formatTimeoutAnnotation(timeoutMs?: number): string | undefined {
|
|
|
209
209
|
return `Command timed out after ${secs} seconds`;
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
function formatKernelTimeoutAnnotation(timeoutMs: number | undefined, kernelKilled: boolean): string {
|
|
213
|
+
const secs = timeoutMs === undefined ? undefined : Math.max(1, Math.round(timeoutMs / 1000));
|
|
214
|
+
if (kernelKilled) {
|
|
215
|
+
return "eval cell timed out and the kernel was unresponsive to interrupt; the kernel has been killed and will be recreated on the next call.";
|
|
216
|
+
}
|
|
217
|
+
const duration = secs === undefined ? "the configured timeout" : `${secs}s`;
|
|
218
|
+
return `eval cell timed out after ${duration}; kernel interrupted but remains running. Reset the kernel via { reset: true } if state appears corrupted.`;
|
|
219
|
+
}
|
|
220
|
+
|
|
212
221
|
function createCancelledPythonResult(timedOut: boolean, timeoutMs?: number): PythonResult {
|
|
213
222
|
const output = timedOut ? (formatTimeoutAnnotation(timeoutMs) ?? "Command timed out") : "";
|
|
214
223
|
const outputBytes = Buffer.byteLength(output, "utf-8");
|
|
@@ -434,7 +443,9 @@ async function executeWithKernel(
|
|
|
434
443
|
});
|
|
435
444
|
|
|
436
445
|
if (result.cancelled) {
|
|
437
|
-
const annotation = result.timedOut
|
|
446
|
+
const annotation = result.timedOut
|
|
447
|
+
? formatKernelTimeoutAnnotation(executionTimeoutMs, result.kernelKilled ?? false)
|
|
448
|
+
: undefined;
|
|
438
449
|
return {
|
|
439
450
|
exitCode: undefined,
|
|
440
451
|
cancelled: true,
|
package/src/eval/py/kernel.ts
CHANGED
|
@@ -46,8 +46,10 @@ const STARTUP_TIMEOUT_MS = 10_000;
|
|
|
46
46
|
// How long to wait after SIGINT for the runner to emit `done`. If the cell is
|
|
47
47
|
// stuck in code that ignores Python signals (e.g. a C extension holding the
|
|
48
48
|
// GIL), we escalate to a full subprocess shutdown so the host queue unblocks
|
|
49
|
-
// instead of hanging the session forever.
|
|
50
|
-
|
|
49
|
+
// instead of hanging the session forever. The grace window is intentionally
|
|
50
|
+
// generous: a clean interrupt is far preferable to losing the persistent
|
|
51
|
+
// kernel's state, so we only kill as a last-resort recovery path.
|
|
52
|
+
const INTERRUPT_ESCALATION_MS = 5_000;
|
|
51
53
|
|
|
52
54
|
export interface KernelExecuteOptions {
|
|
53
55
|
signal?: AbortSignal;
|
|
@@ -66,6 +68,12 @@ export interface KernelExecuteResult {
|
|
|
66
68
|
cancelled: boolean;
|
|
67
69
|
timedOut: boolean;
|
|
68
70
|
stdinRequested: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* True when the kernel subprocess was killed as part of settling this
|
|
73
|
+
* execution (e.g. SIGINT was ignored and we escalated to shutdown, or the
|
|
74
|
+
* kernel died unexpectedly). When false, the kernel remains reusable.
|
|
75
|
+
*/
|
|
76
|
+
kernelKilled?: boolean;
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
export interface KernelShutdownResult {
|
|
@@ -162,6 +170,7 @@ interface PendingExecution {
|
|
|
162
170
|
cancelled: boolean;
|
|
163
171
|
timedOut: boolean;
|
|
164
172
|
stdinRequested: boolean;
|
|
173
|
+
kernelKilled: boolean;
|
|
165
174
|
settled: boolean;
|
|
166
175
|
escalationTimer?: NodeJS.Timeout;
|
|
167
176
|
}
|
|
@@ -222,7 +231,7 @@ export class PythonKernel {
|
|
|
222
231
|
kernel.#exitedPromise = proc.exited;
|
|
223
232
|
void kernel.#exitedPromise.then(code => {
|
|
224
233
|
kernel.#alive = false;
|
|
225
|
-
kernel.#abortPendingExecutions(`Python kernel exited with code ${code}
|
|
234
|
+
kernel.#abortPendingExecutions(`Python kernel exited with code ${code}`, { kernelKilled: true });
|
|
226
235
|
});
|
|
227
236
|
|
|
228
237
|
kernel.#startReader(proc.stdout as ReadableStream<Uint8Array>);
|
|
@@ -261,6 +270,7 @@ export class PythonKernel {
|
|
|
261
270
|
timedOut: false,
|
|
262
271
|
stdinRequested: false,
|
|
263
272
|
settled: false,
|
|
273
|
+
kernelKilled: false,
|
|
264
274
|
};
|
|
265
275
|
this.#pending.set(msgId, pending);
|
|
266
276
|
|
|
@@ -276,6 +286,7 @@ export class PythonKernel {
|
|
|
276
286
|
cancelled: pending.cancelled,
|
|
277
287
|
timedOut: pending.timedOut,
|
|
278
288
|
stdinRequested: pending.stdinRequested,
|
|
289
|
+
kernelKilled: pending.kernelKilled,
|
|
279
290
|
});
|
|
280
291
|
};
|
|
281
292
|
|
|
@@ -287,9 +298,12 @@ export class PythonKernel {
|
|
|
287
298
|
logger.warn("Python runner did not respond to SIGINT; terminating subprocess", {
|
|
288
299
|
kernelId: this.id,
|
|
289
300
|
});
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
301
|
+
// SIGINT was ignored; mark the cell as kernel-killed so callers can
|
|
302
|
+
// surface the harsher recovery message. `shutdown()` aborts pending
|
|
303
|
+
// executions immediately and escalates to SIGTERM/SIGKILL, so the
|
|
304
|
+
// host queue unblocks even if the runner is stuck in a
|
|
305
|
+
// non-interruptible state.
|
|
306
|
+
pending.kernelKilled = true;
|
|
293
307
|
void this.shutdown();
|
|
294
308
|
}, INTERRUPT_ESCALATION_MS);
|
|
295
309
|
escalation.unref?.();
|
|
@@ -363,7 +377,7 @@ export class PythonKernel {
|
|
|
363
377
|
if (this.#shutdownConfirmed) return { confirmed: true };
|
|
364
378
|
|
|
365
379
|
this.#alive = false;
|
|
366
|
-
this.#abortPendingExecutions("Python kernel shutdown");
|
|
380
|
+
this.#abortPendingExecutions("Python kernel shutdown", { kernelKilled: true });
|
|
367
381
|
|
|
368
382
|
const timeoutMs = options?.timeoutMs ?? SHUTDOWN_GRACE_MS;
|
|
369
383
|
const proc = this.#proc;
|
|
@@ -410,10 +424,11 @@ export class PythonKernel {
|
|
|
410
424
|
return { confirmed };
|
|
411
425
|
}
|
|
412
426
|
|
|
413
|
-
#abortPendingExecutions(reason: string): void {
|
|
427
|
+
#abortPendingExecutions(reason: string, options?: { kernelKilled?: boolean }): void {
|
|
414
428
|
if (this.#pending.size === 0) return;
|
|
415
429
|
const pending = Array.from(this.#pending.values());
|
|
416
430
|
this.#pending.clear();
|
|
431
|
+
const kernelKilledDefault = options?.kernelKilled ?? false;
|
|
417
432
|
for (const entry of pending) {
|
|
418
433
|
if (entry.settled) continue;
|
|
419
434
|
entry.settled = true;
|
|
@@ -425,6 +440,7 @@ export class PythonKernel {
|
|
|
425
440
|
stdinRequested: entry.stdinRequested,
|
|
426
441
|
executionCount: entry.executionCount,
|
|
427
442
|
error: entry.error,
|
|
443
|
+
kernelKilled: entry.kernelKilled || kernelKilledDefault,
|
|
428
444
|
});
|
|
429
445
|
}
|
|
430
446
|
}
|
|
@@ -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()) {
|
package/src/main.ts
CHANGED
|
@@ -199,7 +199,7 @@ function applyExtensionFlagValues(session: AgentSession, rawArgs: string[]): Map
|
|
|
199
199
|
|
|
200
200
|
type AcpSessionFactory = (cwd: string) => Promise<AgentSession>;
|
|
201
201
|
|
|
202
|
-
interface AcpSessionFactoryOptions {
|
|
202
|
+
export interface AcpSessionFactoryOptions {
|
|
203
203
|
baseOptions: CreateAgentSessionOptions;
|
|
204
204
|
settings: Settings;
|
|
205
205
|
sessionDir?: string;
|
|
@@ -210,7 +210,17 @@ interface AcpSessionFactoryOptions {
|
|
|
210
210
|
createSession: (options: CreateAgentSessionOptions) => Promise<CreateAgentSessionResult>;
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
-
|
|
213
|
+
/**
|
|
214
|
+
* Build the per-`session/new` factory used by ACP mode.
|
|
215
|
+
*
|
|
216
|
+
* MCP servers in ACP sessions are owned exclusively by the ACP client, which
|
|
217
|
+
* supplies them through `session/new.mcpServers` and re-applies them via
|
|
218
|
+
* {@link AcpAgent#configureMcpServers}. We therefore force `enableMCP: false`
|
|
219
|
+
* on every session created here so {@link createAgentSession} skips the on-disk
|
|
220
|
+
* `.mcp.json` discovery path — otherwise host MCP tools land in the session's
|
|
221
|
+
* tool registry and shadow the client-supplied servers (issue #1234).
|
|
222
|
+
*/
|
|
223
|
+
export function createAcpSessionFactory(args: AcpSessionFactoryOptions): AcpSessionFactory {
|
|
214
224
|
return async cwd => {
|
|
215
225
|
const nextSettings = await args.settings.cloneForCwd(cwd);
|
|
216
226
|
const nextSessionManager = SessionManager.create(cwd, args.sessionDir);
|
|
@@ -224,6 +234,7 @@ function createAcpSessionFactory(args: AcpSessionFactoryOptions): AcpSessionFact
|
|
|
224
234
|
modelRegistry: args.modelRegistry,
|
|
225
235
|
agentId,
|
|
226
236
|
hasUI: false,
|
|
237
|
+
enableMCP: false,
|
|
227
238
|
});
|
|
228
239
|
if (args.parsedArgs.apiKey && !args.baseOptions.model && nextSession.model) {
|
|
229
240
|
args.authStorage.setRuntimeApiKey(nextSession.model.provider, args.parsedArgs.apiKey);
|