@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/dist/types/cli/update-cli.d.ts +18 -0
  3. package/dist/types/config/settings-schema.d.ts +10 -0
  4. package/dist/types/eval/py/kernel.d.ts +6 -0
  5. package/dist/types/goals/state.d.ts +1 -1
  6. package/dist/types/goals/tools/goal-tool.d.ts +4 -0
  7. package/dist/types/hashline/parser.d.ts +6 -2
  8. package/dist/types/internal-urls/memory-protocol.d.ts +6 -0
  9. package/dist/types/main.d.ts +25 -1
  10. package/dist/types/modes/theme/shimmer.d.ts +27 -0
  11. package/dist/types/slash-commands/helpers/format.d.ts +4 -1
  12. package/dist/types/tools/ast-edit.d.ts +3 -0
  13. package/dist/types/tools/ast-grep.d.ts +3 -0
  14. package/dist/types/tools/find.d.ts +3 -0
  15. package/dist/types/tools/search.d.ts +3 -0
  16. package/dist/types/tui/file-list.d.ts +6 -0
  17. package/dist/types/tui/hyperlink.d.ts +42 -0
  18. package/dist/types/tui/index.d.ts +1 -0
  19. package/dist/types/utils/tool-choice.d.ts +2 -1
  20. package/dist/types/web/search/providers/utils.d.ts +27 -1
  21. package/package.json +7 -7
  22. package/src/cli/update-cli.ts +78 -36
  23. package/src/config/model-registry.ts +23 -12
  24. package/src/config/settings-schema.ts +12 -0
  25. package/src/config/settings.ts +28 -5
  26. package/src/edit/renderer.ts +5 -3
  27. package/src/eval/py/executor.ts +12 -1
  28. package/src/eval/py/kernel.ts +24 -8
  29. package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
  30. package/src/goals/runtime.ts +9 -3
  31. package/src/goals/state.ts +1 -1
  32. package/src/goals/tools/goal-tool.ts +12 -2
  33. package/src/hashline/diff.ts +1 -1
  34. package/src/hashline/execute.ts +2 -2
  35. package/src/hashline/parser.ts +87 -12
  36. package/src/internal-urls/memory-protocol.ts +1 -1
  37. package/src/main.ts +13 -2
  38. package/src/modes/interactive-mode.ts +29 -1
  39. package/src/modes/theme/shimmer.ts +79 -0
  40. package/src/prompts/agents/oracle.md +15 -16
  41. package/src/prompts/tools/goal.md +7 -2
  42. package/src/session/agent-session.ts +12 -75
  43. package/src/slash-commands/helpers/format.ts +23 -3
  44. package/src/task/executor.ts +115 -19
  45. package/src/tools/ast-edit.ts +39 -6
  46. package/src/tools/ast-grep.ts +38 -6
  47. package/src/tools/find.ts +13 -2
  48. package/src/tools/read.ts +46 -6
  49. package/src/tools/search.ts +447 -265
  50. package/src/tui/file-list.ts +10 -2
  51. package/src/tui/hyperlink.ts +126 -0
  52. package/src/tui/index.ts +1 -0
  53. package/src/utils/tool-choice.ts +7 -7
  54. package/src/web/kagi.ts +2 -2
  55. package/src/web/parallel.ts +3 -3
  56. package/src/web/search/index.ts +20 -9
  57. package/src/web/search/providers/anthropic.ts +4 -2
  58. package/src/web/search/providers/brave.ts +4 -2
  59. package/src/web/search/providers/codex.ts +4 -1
  60. package/src/web/search/providers/exa.ts +4 -1
  61. package/src/web/search/providers/gemini.ts +4 -1
  62. package/src/web/search/providers/jina.ts +4 -2
  63. package/src/web/search/providers/kagi.ts +5 -1
  64. package/src/web/search/providers/kimi.ts +4 -2
  65. package/src/web/search/providers/parallel.ts +5 -1
  66. package/src/web/search/providers/perplexity.ts +7 -2
  67. package/src/web/search/providers/searxng.ts +4 -1
  68. package/src/web/search/providers/synthetic.ts +4 -2
  69. package/src/web/search/providers/tavily.ts +4 -2
  70. package/src/web/search/providers/utils.ts +63 -1
  71. 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
- if (!configuredProviders.has("ollama")) {
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 selectedDiscoverableProviders = providerFilter
1164
- ? this.#discoverableProviders.filter(provider => providerFilter.has(provider.provider))
1165
- : this.#discoverableProviders;
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(PROVIDER_DESCRIPTORS.map(peekKey)),
1375
- Promise.all(specialProviderDescriptors.map(peekKey)),
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 < PROVIDER_DESCRIPTORS.length; i++) {
1379
- const descriptor = PROVIDER_DESCRIPTORS[i];
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 < specialProviderDescriptors.length; i++) {
1392
- const descriptor = specialProviderDescriptors[i];
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",
@@ -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.get("modelRoles");
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 prev = this.get("modelRoles");
473
+ const next = shallowStringRecord(getByPath(this.#overrides, ["modelRoles"]));
451
474
  for (const [role, modelId] of Object.entries(roles)) {
452
475
  if (modelId) {
453
- prev[role] = modelId;
476
+ next[role] = modelId;
454
477
  }
455
478
  }
456
- this.override("modelRoles", prev);
479
+ this.override("modelRoles", next);
457
480
  }
458
481
 
459
482
  /**
@@ -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 ? uiTheme.fg("accent", shortenPath(rawPath)) : uiTheme.fg("toolOutput", "…");
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;
@@ -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 ? formatTimeoutAnnotation(executionTimeoutMs) : undefined;
446
+ const annotation = result.timedOut
447
+ ? formatKernelTimeoutAnnotation(executionTimeoutMs, result.kernelKilled ?? false)
448
+ : undefined;
438
449
  return {
439
450
  exitCode: undefined,
440
451
  cancelled: true,
@@ -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
- const INTERRUPT_ESCALATION_MS = 2_000;
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
- // `shutdown()` aborts pending executions immediately and escalates to
291
- // SIGTERM/SIGKILL, so the host queue unblocks even if the runner is
292
- // stuck in a non-interruptible state.
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, `${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()) {
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
- function createAcpSessionFactory(args: AcpSessionFactoryOptions): AcpSessionFactory {
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);