@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.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 (140) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +9 -9
  4. package/scripts/build-binary.ts +5 -0
  5. package/src/autoresearch/helpers.ts +17 -0
  6. package/src/autoresearch/tools/log-experiment.ts +9 -17
  7. package/src/autoresearch/tools/run-experiment.ts +2 -17
  8. package/src/capability/skill.ts +7 -0
  9. package/src/cli/list-models.ts +1 -1
  10. package/src/cli/shell-cli.ts +3 -13
  11. package/src/cli/update-cli.ts +1 -1
  12. package/src/cli.ts +10 -29
  13. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  14. package/src/commit/analysis/conventional.ts +8 -66
  15. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  16. package/src/commit/pipeline.ts +2 -2
  17. package/src/commit/shared-llm.ts +89 -0
  18. package/src/config/config-file.ts +210 -0
  19. package/src/config/model-equivalence.ts +8 -11
  20. package/src/config/model-registry.ts +13 -2
  21. package/src/config/model-resolver.ts +1 -4
  22. package/src/config/settings-schema.ts +71 -1
  23. package/src/config/settings.ts +1 -1
  24. package/src/config.ts +3 -219
  25. package/src/edit/renderer.ts +7 -1
  26. package/src/eval/js/executor.ts +3 -0
  27. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  28. package/src/eval/py/executor.ts +5 -0
  29. package/src/exa/factory.ts +2 -2
  30. package/src/exa/mcp-client.ts +74 -1
  31. package/src/exec/bash-executor.ts +5 -1
  32. package/src/export/html/template.generated.ts +1 -1
  33. package/src/export/html/template.js +0 -11
  34. package/src/extensibility/extensions/runner.ts +1 -1
  35. package/src/extensibility/extensions/types.ts +89 -223
  36. package/src/extensibility/hooks/types.ts +89 -314
  37. package/src/extensibility/shared-events.ts +343 -0
  38. package/src/extensibility/skills.ts +9 -0
  39. package/src/goals/index.ts +3 -0
  40. package/src/goals/runtime.ts +500 -0
  41. package/src/goals/state.ts +37 -0
  42. package/src/goals/tools/goal-tool.ts +237 -0
  43. package/src/hashline/anchors.ts +2 -2
  44. package/src/hindsight/mental-models.ts +1 -1
  45. package/src/internal-urls/agent-protocol.ts +1 -20
  46. package/src/internal-urls/artifact-protocol.ts +1 -19
  47. package/src/internal-urls/docs-index.generated.ts +5 -6
  48. package/src/internal-urls/registry-helpers.ts +25 -0
  49. package/src/main.ts +11 -2
  50. package/src/mcp/oauth-flow.ts +20 -0
  51. package/src/modes/acp/acp-agent.ts +79 -45
  52. package/src/modes/components/assistant-message.ts +14 -8
  53. package/src/modes/components/bash-execution.ts +24 -63
  54. package/src/modes/components/custom-message.ts +14 -40
  55. package/src/modes/components/eval-execution.ts +27 -57
  56. package/src/modes/components/execution-shared.ts +102 -0
  57. package/src/modes/components/hook-message.ts +17 -49
  58. package/src/modes/components/mcp-add-wizard.ts +26 -5
  59. package/src/modes/components/message-frame.ts +88 -0
  60. package/src/modes/components/model-selector.ts +1 -1
  61. package/src/modes/components/session-observer-overlay.ts +6 -2
  62. package/src/modes/components/session-selector.ts +1 -1
  63. package/src/modes/components/status-line/segments.ts +55 -4
  64. package/src/modes/components/status-line/types.ts +4 -0
  65. package/src/modes/components/status-line.ts +28 -10
  66. package/src/modes/components/tool-execution.ts +7 -8
  67. package/src/modes/controllers/command-controller-shared.ts +108 -0
  68. package/src/modes/controllers/command-controller.ts +13 -4
  69. package/src/modes/controllers/event-controller.ts +36 -7
  70. package/src/modes/controllers/input-controller.ts +13 -0
  71. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  72. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  73. package/src/modes/interactive-mode.ts +624 -52
  74. package/src/modes/print-mode.ts +16 -86
  75. package/src/modes/rpc/rpc-mode.ts +14 -87
  76. package/src/modes/runtime-init.ts +115 -0
  77. package/src/modes/theme/defaults/dark-poimandres.json +2 -0
  78. package/src/modes/theme/defaults/light-poimandres.json +2 -0
  79. package/src/modes/theme/theme.ts +18 -6
  80. package/src/modes/types.ts +14 -3
  81. package/src/modes/utils/context-usage.ts +13 -13
  82. package/src/modes/utils/ui-helpers.ts +10 -3
  83. package/src/plan-mode/approved-plan.ts +35 -1
  84. package/src/prompts/goals/goal-budget-limit.md +16 -0
  85. package/src/prompts/goals/goal-continuation.md +28 -0
  86. package/src/prompts/goals/goal-mode-active.md +23 -0
  87. package/src/prompts/system/plan-mode-active.md +5 -5
  88. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  89. package/src/prompts/tools/bash.md +6 -0
  90. package/src/prompts/tools/goal.md +13 -0
  91. package/src/prompts/tools/hashline.md +102 -114
  92. package/src/prompts/tools/read.md +1 -0
  93. package/src/prompts/tools/resolve.md +6 -5
  94. package/src/sdk.ts +12 -5
  95. package/src/session/agent-session.ts +428 -106
  96. package/src/session/blob-store.ts +36 -3
  97. package/src/session/messages.ts +67 -2
  98. package/src/session/session-manager.ts +131 -12
  99. package/src/session/session-storage.ts +33 -15
  100. package/src/session/streaming-output.ts +309 -13
  101. package/src/slash-commands/builtin-registry.ts +18 -0
  102. package/src/ssh/ssh-executor.ts +5 -0
  103. package/src/system-prompt.ts +4 -2
  104. package/src/task/executor.ts +17 -7
  105. package/src/task/index.ts +3 -0
  106. package/src/task/render.ts +21 -15
  107. package/src/task/types.ts +4 -0
  108. package/src/tools/ast-edit.ts +21 -120
  109. package/src/tools/ast-grep.ts +21 -119
  110. package/src/tools/bash-interactive.ts +9 -1
  111. package/src/tools/bash.ts +27 -4
  112. package/src/tools/browser/attach.ts +3 -3
  113. package/src/tools/browser/launch.ts +81 -18
  114. package/src/tools/browser/registry.ts +1 -5
  115. package/src/tools/browser/tab-supervisor.ts +51 -14
  116. package/src/tools/conflict-detect.ts +15 -4
  117. package/src/tools/eval.ts +3 -1
  118. package/src/tools/find.ts +20 -38
  119. package/src/tools/gh.ts +7 -6
  120. package/src/tools/index.ts +22 -11
  121. package/src/tools/inspect-image.ts +3 -10
  122. package/src/tools/output-meta.ts +176 -37
  123. package/src/tools/path-utils.ts +125 -2
  124. package/src/tools/read.ts +516 -233
  125. package/src/tools/render-utils.ts +92 -0
  126. package/src/tools/renderers.ts +2 -0
  127. package/src/tools/resolve.ts +72 -44
  128. package/src/tools/search.ts +120 -186
  129. package/src/tools/write.ts +44 -9
  130. package/src/utils/file-mentions.ts +1 -1
  131. package/src/utils/image-loading.ts +7 -3
  132. package/src/utils/image-resize.ts +32 -43
  133. package/src/vim/parser.ts +0 -17
  134. package/src/vim/render.ts +1 -1
  135. package/src/vim/types.ts +1 -1
  136. package/src/web/search/providers/gemini.ts +35 -95
  137. package/src/prompts/tools/exit-plan-mode.md +0 -6
  138. package/src/tools/exit-plan-mode.ts +0 -97
  139. package/src/utils/fuzzy.ts +0 -108
  140. package/src/utils/image-convert.ts +0 -27
@@ -0,0 +1,210 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
4
+ import type { TSchema } from "@sinclair/typebox";
5
+ import { Value } from "@sinclair/typebox/value";
6
+ import type { ErrorObject } from "ajv";
7
+ import { JSONC, YAML } from "bun";
8
+
9
+ function migrateJsonToYml(jsonPath: string, ymlPath: string) {
10
+ try {
11
+ if (fs.existsSync(ymlPath)) return;
12
+ if (!fs.existsSync(jsonPath)) return;
13
+
14
+ const content = fs.readFileSync(jsonPath, "utf-8");
15
+ const parsed = JSON.parse(content);
16
+ if (!parsed) {
17
+ logger.warn("migrateJsonToYml: invalid json structure", { path: jsonPath });
18
+ return;
19
+ }
20
+ fs.writeFileSync(ymlPath, YAML.stringify(parsed, null, 2));
21
+ } catch (error) {
22
+ logger.warn("migrateJsonToYml: migration failed", { error: String(error) });
23
+ }
24
+ }
25
+
26
+ export interface IConfigFile<T> {
27
+ readonly id: string;
28
+ readonly schema: TSchema;
29
+ path?(): string;
30
+ load(): T | null;
31
+ invalidate?(): void;
32
+ }
33
+
34
+ export class ConfigError extends Error {
35
+ readonly #message: string;
36
+ constructor(
37
+ public readonly id: string,
38
+ public readonly schemaErrors: ErrorObject[] | null | undefined,
39
+ public readonly other?: { err: unknown; stage: string },
40
+ ) {
41
+ let messages: string[] | undefined;
42
+ let cause: Error | undefined;
43
+ let klass: string;
44
+
45
+ if (schemaErrors) {
46
+ klass = "Schema";
47
+ messages = schemaErrors.map(e => `${e.instancePath || "root"}: ${e.message}`);
48
+ } else if (other) {
49
+ klass = other.stage;
50
+ if (other.err instanceof Error) {
51
+ messages = [other.err.message];
52
+ cause = other.err;
53
+ } else {
54
+ messages = [String(other.err)];
55
+ }
56
+ } else {
57
+ klass = "Unknown";
58
+ }
59
+
60
+ const title = `Failed to load config file ${id}, ${klass} error:`;
61
+ let message: string;
62
+ switch (messages?.length ?? 0) {
63
+ case 0:
64
+ message = title.slice(0, -1);
65
+ break;
66
+ case 1:
67
+ message = `${title} ${messages![0]}`;
68
+ break;
69
+ default:
70
+ message = `${title}\n${messages!.map(m => ` - ${m}`).join("\n")}`;
71
+ break;
72
+ }
73
+
74
+ super(message, { cause });
75
+ this.name = "LoadError";
76
+ this.#message = message;
77
+ }
78
+
79
+ get message(): string {
80
+ return this.#message;
81
+ }
82
+
83
+ toString(): string {
84
+ return this.message;
85
+ }
86
+ }
87
+
88
+ export type LoadStatus = "ok" | "error" | "not-found";
89
+
90
+ export type LoadResult<T> =
91
+ | { value?: null; error: ConfigError; status: "error" }
92
+ | { value: T; error?: undefined; status: "ok" }
93
+ | { value?: null; error?: unknown; status: "not-found" };
94
+
95
+ export class ConfigFile<T> implements IConfigFile<T> {
96
+ readonly #basePath: string;
97
+ #cache?: LoadResult<T>;
98
+ #auxValidate?: (value: T) => void;
99
+
100
+ constructor(
101
+ readonly id: string,
102
+ readonly schema: TSchema,
103
+ configPath: string = path.join(getAgentDir(), `${id}.yml`),
104
+ ) {
105
+ this.#basePath = configPath;
106
+ if (configPath.endsWith(".yml")) {
107
+ const jsonPath = `${configPath.slice(0, -4)}.json`;
108
+ migrateJsonToYml(jsonPath, configPath);
109
+ } else if (configPath.endsWith(".yaml")) {
110
+ const jsonPath = `${configPath.slice(0, -5)}.json`;
111
+ migrateJsonToYml(jsonPath, configPath);
112
+ } else if (configPath.endsWith(".json") || configPath.endsWith(".jsonc")) {
113
+ // JSON configs are still supported without migration.
114
+ } else {
115
+ throw new Error(`Invalid config file path: ${configPath}`);
116
+ }
117
+ }
118
+
119
+ relocate(configPath?: string): ConfigFile<T> {
120
+ if (!configPath || configPath === this.#basePath) return this;
121
+ const result = new ConfigFile<T>(this.id, this.schema, configPath);
122
+ result.#auxValidate = this.#auxValidate;
123
+ return result;
124
+ }
125
+
126
+ getMtimeMs(): number | null {
127
+ try {
128
+ return fs.statSync(this.path()).mtimeMs;
129
+ } catch (err) {
130
+ if (isEnoent(err)) return null;
131
+ throw err;
132
+ }
133
+ }
134
+
135
+ withValidation(name: string, validate: (value: T) => void): this {
136
+ const prev = this.#auxValidate;
137
+ this.#auxValidate = (value: T) => {
138
+ prev?.(value);
139
+ try {
140
+ validate(value);
141
+ } catch (error) {
142
+ throw new ConfigError(this.id, undefined, { err: error, stage: `Validate(${name})` });
143
+ }
144
+ };
145
+ return this;
146
+ }
147
+
148
+ createDefault(): T {
149
+ return Value.Default(this.schema, [], undefined) as T;
150
+ }
151
+
152
+ #storeCache(result: LoadResult<T>): LoadResult<T> {
153
+ this.#cache = result;
154
+ return result;
155
+ }
156
+
157
+ tryLoad(): LoadResult<T> {
158
+ if (this.#cache) return this.#cache;
159
+
160
+ try {
161
+ const content = fs.readFileSync(this.path(), "utf-8").trim();
162
+
163
+ let parsed: unknown;
164
+ if (this.#basePath.endsWith(".json") || this.#basePath.endsWith(".jsonc")) {
165
+ parsed = JSONC.parse(content);
166
+ } else if (this.#basePath.endsWith(".yml") || this.#basePath.endsWith(".yaml")) {
167
+ parsed = YAML.parse(content);
168
+ } else {
169
+ throw new Error(`Invalid config file path: ${this.#basePath}`);
170
+ }
171
+
172
+ if (!Value.Check(this.schema, parsed)) {
173
+ const schemaErrors: ErrorObject[] = [];
174
+ for (const err of Value.Errors(this.schema, parsed)) {
175
+ schemaErrors.push({ instancePath: err.path, message: err.message } as ErrorObject);
176
+ if (schemaErrors.length >= 50) break;
177
+ }
178
+ const error = new ConfigError(this.id, schemaErrors);
179
+ logger.warn("Failed to parse config file", { path: this.path(), error });
180
+ return this.#storeCache({ error, status: "error" });
181
+ }
182
+ return this.#storeCache({ value: parsed as T, status: "ok" });
183
+ } catch (error) {
184
+ if (isEnoent(error)) {
185
+ return this.#storeCache({ status: "not-found" });
186
+ }
187
+ logger.warn("Failed to parse config file", { path: this.path(), error });
188
+ return this.#storeCache({
189
+ error: new ConfigError(this.id, undefined, { err: error, stage: "Unexpected" }),
190
+ status: "error",
191
+ });
192
+ }
193
+ }
194
+
195
+ load(): T | null {
196
+ return this.tryLoad().value ?? null;
197
+ }
198
+
199
+ loadOrDefault(): T {
200
+ return this.tryLoad().value ?? this.createDefault();
201
+ }
202
+
203
+ path(): string {
204
+ return this.#basePath;
205
+ }
206
+
207
+ invalidate() {
208
+ this.#cache = undefined;
209
+ }
210
+ }
@@ -72,15 +72,12 @@ const TRAILING_MARKER_SUFFIXES: readonly string[] = (() => {
72
72
  })();
73
73
  const WRAPPER_PREFIXES = ["duo-chat-"] as const;
74
74
 
75
- let __referenceDataCache: CanonicalReferenceData | undefined;
75
+ let referenceDataCache: CanonicalReferenceData | undefined;
76
76
  const EMPTY_COMPILED_EQUIVALENCE: CompiledEquivalenceConfig = {
77
77
  overrides: new Map<string, string>(),
78
78
  exclude: new Set<string>(),
79
79
  };
80
- const __resolutionCache: WeakMap<
81
- CompiledEquivalenceConfig,
82
- WeakMap<Model<Api>, ResolvedCanonicalModel>
83
- > = new WeakMap();
80
+ const resolutionCache: WeakMap<CompiledEquivalenceConfig, WeakMap<Model<Api>, ResolvedCanonicalModel>> = new WeakMap();
84
81
  const FAMILY_EXTRACTION_PATTERNS = [
85
82
  /(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+)(?::|$)/i,
86
83
  /(?:^|[/:._-])((?:claude|gemini|gpt|grok|glm|qwen|minimax|kimi|deepseek|llama|gemma|nova|mistral|ministral|pixtral|codestral|devstral|magistral|ernie|doubao|seed|aion|olmo|molmo|nemotron|palmyra|command|codex|coder|o[1345])[-a-z0-9.]+(?:[-_/][a-z0-9.]+)*)(?::|$)/i,
@@ -98,8 +95,8 @@ function shouldReplaceReference(existing: Model<Api> | undefined, candidate: Mod
98
95
  }
99
96
 
100
97
  function createCanonicalReferenceData(): CanonicalReferenceData {
101
- if (__referenceDataCache) {
102
- return __referenceDataCache;
98
+ if (referenceDataCache) {
99
+ return referenceDataCache;
103
100
  }
104
101
  const references = new Map<string, Model<Api>>();
105
102
  for (const provider of getBundledProviders()) {
@@ -112,11 +109,11 @@ function createCanonicalReferenceData(): CanonicalReferenceData {
112
109
  }
113
110
  }
114
111
  const officialIds = new Set(references.keys());
115
- __referenceDataCache = {
112
+ referenceDataCache = {
116
113
  references: Object.freeze(references) as Map<string, Model<Api>>,
117
114
  officialIds: Object.freeze(officialIds) as Set<string>,
118
115
  };
119
- return __referenceDataCache;
116
+ return referenceDataCache;
120
117
  }
121
118
 
122
119
  function normalizeSelectorKey(selector: string): string {
@@ -668,10 +665,10 @@ export function buildCanonicalModelIndex(
668
665
  const byId = new Map<string, CanonicalModelRecord>();
669
666
  const bySelector = new Map<string, string>();
670
667
 
671
- let modelCache = __resolutionCache.get(compiledEquivalence);
668
+ let modelCache = resolutionCache.get(compiledEquivalence);
672
669
  if (!modelCache) {
673
670
  modelCache = new WeakMap<Model<Api>, ResolvedCanonicalModel>();
674
- __resolutionCache.set(compiledEquivalence, modelCache);
671
+ resolutionCache.set(compiledEquivalence, modelCache);
675
672
  }
676
673
 
677
674
  for (const model of models) {
@@ -18,6 +18,8 @@ import {
18
18
  registerCustomApi,
19
19
  type SimpleStreamOptions,
20
20
  type ThinkingConfig,
21
+ UNK_CONTEXT_WINDOW,
22
+ UNK_MAX_TOKENS,
21
23
  unregisterCustomApis,
22
24
  } from "@oh-my-pi/pi-ai";
23
25
 
@@ -29,10 +31,10 @@ import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai
29
31
  import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types";
30
32
  import { isRecord, logger } from "@oh-my-pi/pi-utils";
31
33
  import { type Static, Type } from "@sinclair/typebox";
32
- import { type ConfigError, ConfigFile } from "../config";
33
34
  import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
34
35
  import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
35
36
  import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
37
+ import { type ConfigError, ConfigFile } from "./config-file";
36
38
  import {
37
39
  buildCanonicalModelIndex,
38
40
  type CanonicalModelIndex,
@@ -1053,7 +1055,16 @@ export class ModelRegistry {
1053
1055
  const key = `${replacementModel.provider}\u0000${replacementModel.id}`;
1054
1056
  const existingIndex = indexByKey.get(key);
1055
1057
  if (existingIndex !== undefined) {
1056
- merged[existingIndex] = replacementModel;
1058
+ const existing = merged[existingIndex];
1059
+ merged[existingIndex] = {
1060
+ ...replacementModel,
1061
+ contextWindow:
1062
+ replacementModel.contextWindow === UNK_CONTEXT_WINDOW
1063
+ ? existing.contextWindow
1064
+ : replacementModel.contextWindow,
1065
+ maxTokens:
1066
+ replacementModel.maxTokens === UNK_MAX_TOKENS ? existing.maxTokens : replacementModel.maxTokens,
1067
+ };
1057
1068
  } else {
1058
1069
  merged.push(replacementModel);
1059
1070
  indexByKey.set(key, merged.length - 1);
@@ -12,10 +12,10 @@ import {
12
12
  type Model,
13
13
  modelsAreEqual,
14
14
  } from "@oh-my-pi/pi-ai";
15
+ import { fuzzyMatch } from "@oh-my-pi/pi-tui";
15
16
  import chalk from "chalk";
16
17
  import MODEL_PRIO from "../priority.json" with { type: "json" };
17
18
  import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking";
18
- import { fuzzyMatch } from "../utils/fuzzy";
19
19
  import { isAuthenticated, kNoAuth, MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry";
20
20
  import type { Settings } from "./settings";
21
21
 
@@ -607,9 +607,6 @@ export function resolveModelRoleValue(
607
607
  return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
608
608
  }
609
609
 
610
- const lastColonIndex = normalized.lastIndexOf(":");
611
- const _thinkingSelector =
612
- lastColonIndex > PREFIX_MODEL_ROLE.length ? parseThinkingLevel(normalized.slice(lastColonIndex + 1)) : undefined;
613
610
  const effectivePatterns = resolveConfiguredRolePattern(normalized, options?.settings);
614
611
  if (!effectivePatterns || effectivePatterns.length === 0) {
615
612
  return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
@@ -460,6 +460,46 @@ export const SETTINGS_SCHEMA = {
460
460
  ],
461
461
  },
462
462
  },
463
+ "tools.artifactHeadBytes": {
464
+ type: "number",
465
+ default: 20,
466
+ ui: {
467
+ tab: "tools",
468
+ label: "Artifact head size (KB)",
469
+ description:
470
+ "Amount of head content kept inline alongside the tail when output spills to artifact (middle elision). 0 disables — keep tail only.",
471
+ options: [
472
+ { value: "0", label: "0 KB", description: "Disabled; tail-only truncation" },
473
+ { value: "1", label: "1 KB", description: "~250 tokens" },
474
+ { value: "2.5", label: "2.5 KB", description: "~625 tokens" },
475
+ { value: "5", label: "5 KB", description: "~1.25K tokens" },
476
+ { value: "10", label: "10 KB", description: "~2.5K tokens" },
477
+ { value: "20", label: "20 KB", description: "Default; ~5K tokens" },
478
+ { value: "50", label: "50 KB", description: "~12.5K tokens" },
479
+ { value: "100", label: "100 KB", description: "~25K tokens" },
480
+ { value: "200", label: "200 KB", description: "~50K tokens" },
481
+ ],
482
+ },
483
+ },
484
+ "tools.outputMaxColumns": {
485
+ type: "number",
486
+ default: 768,
487
+ ui: {
488
+ tab: "tools",
489
+ label: "Output column cap",
490
+ description:
491
+ "Per-line byte cap for streaming tool outputs (bash, ssh, python, js eval) and `read`. Lines wider than this are ellipsis-truncated; remaining bytes up to the next newline are dropped. 0 disables.",
492
+ options: [
493
+ { value: "0", label: "Off", description: "No per-line cap" },
494
+ { value: "256", label: "256", description: "Tight" },
495
+ { value: "512", label: "512" },
496
+ { value: "768", label: "768", description: "Default" },
497
+ { value: "1024", label: "1024" },
498
+ { value: "2048", label: "2048" },
499
+ { value: "4096", label: "4096", description: "Loose" },
500
+ ],
501
+ },
502
+ },
463
503
  "tools.artifactTailLines": {
464
504
  type: "number",
465
505
  default: 500,
@@ -1498,7 +1538,7 @@ export const SETTINGS_SCHEMA = {
1498
1538
 
1499
1539
  "read.defaultLimit": {
1500
1540
  type: "number",
1501
- default: 500,
1541
+ default: 300,
1502
1542
  ui: {
1503
1543
  tab: "editing",
1504
1544
  label: "Default Read Limit",
@@ -2095,6 +2135,36 @@ export const SETTINGS_SCHEMA = {
2095
2135
  },
2096
2136
  },
2097
2137
 
2138
+ "goal.enabled": {
2139
+ type: "boolean",
2140
+ default: true,
2141
+ ui: {
2142
+ tab: "tasks",
2143
+ label: "Goal Mode",
2144
+ description: "Enable per-session goal mode and the hidden goal tool",
2145
+ },
2146
+ },
2147
+
2148
+ "goal.statusInFooter": {
2149
+ type: "boolean",
2150
+ default: true,
2151
+ ui: {
2152
+ tab: "tasks",
2153
+ label: "Goal Status In Footer",
2154
+ description: "Show token budget alongside the goal indicator in the status line",
2155
+ },
2156
+ },
2157
+
2158
+ "goal.continuationModes": {
2159
+ type: "array",
2160
+ default: ["interactive"],
2161
+ ui: {
2162
+ tab: "tasks",
2163
+ label: "Goal Continuation Modes",
2164
+ description: "Run modes where active goals may auto-continue between turns",
2165
+ },
2166
+ },
2167
+
2098
2168
  // Delegation
2099
2169
  "task.isolation.mode": {
2100
2170
  type: "enum",
@@ -850,7 +850,7 @@ export function isSettingsInitialized(): boolean {
850
850
  * Reset the global singleton for testing.
851
851
  * @internal
852
852
  */
853
- export function _resetSettingsForTest(): void {
853
+ export function resetSettingsForTest(): void {
854
854
  globalInstance = null;
855
855
  globalInstancePromise = null;
856
856
  }
package/src/config.ts CHANGED
@@ -1,20 +1,11 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import {
5
- CONFIG_DIR_NAME,
6
- getAgentDir,
7
- getConfigAgentDirName,
8
- getProjectDir,
9
- isEnoent,
10
- logger,
11
- } from "@oh-my-pi/pi-utils";
12
- import type { TSchema } from "@sinclair/typebox";
13
- import { Value } from "@sinclair/typebox/value";
14
- import type { ErrorObject } from "ajv";
15
- import { JSONC, YAML } from "bun";
4
+ import { CONFIG_DIR_NAME, getConfigAgentDirName, getProjectDir } from "@oh-my-pi/pi-utils";
16
5
  import { expandTilde } from "./tools/path-utils";
17
6
 
7
+ export * from "./config/config-file";
8
+
18
9
  const priorityList = [
19
10
  { dir: CONFIG_DIR_NAME, globalAgentDir: getConfigAgentDirName },
20
11
  { dir: ".claude" },
@@ -53,213 +44,6 @@ export function getChangelogPath(): string {
53
44
  return path.resolve(path.join(getPackageDir(), "CHANGELOG.md"));
54
45
  }
55
46
 
56
- // =============================================================================
57
- // User Config Paths (~/.omp/agent/*)
58
- // =============================================================================
59
-
60
- function migrateJsonToYml(jsonPath: string, ymlPath: string) {
61
- try {
62
- if (fs.existsSync(ymlPath)) return;
63
- if (!fs.existsSync(jsonPath)) return;
64
-
65
- const content = fs.readFileSync(jsonPath, "utf-8");
66
- const parsed = JSON.parse(content);
67
- if (!parsed) {
68
- logger.warn("migrateJsonToYml: invalid json structure", { path: jsonPath });
69
- return;
70
- }
71
- fs.writeFileSync(ymlPath, YAML.stringify(parsed, null, 2));
72
- } catch (error) {
73
- logger.warn("migrateJsonToYml: migration failed", { error: String(error) });
74
- }
75
- }
76
-
77
- export interface IConfigFile<T> {
78
- readonly id: string;
79
- readonly schema: TSchema;
80
- path?(): string;
81
- load(): T | null;
82
- invalidate?(): void;
83
- }
84
-
85
- export class ConfigError extends Error {
86
- readonly #message: string;
87
- constructor(
88
- public readonly id: string,
89
- public readonly schemaErrors: ErrorObject[] | null | undefined,
90
- public readonly other?: { err: unknown; stage: string },
91
- ) {
92
- let messages: string[] | undefined;
93
- let cause: any | undefined;
94
- let klass: string;
95
-
96
- if (schemaErrors) {
97
- klass = "Schema";
98
- messages = schemaErrors.map(e => `${e.instancePath || "root"}: ${e.message}`);
99
- } else if (other) {
100
- klass = other.stage;
101
- if (other.err instanceof Error) {
102
- messages = [other.err.message];
103
- cause = other.err;
104
- } else {
105
- messages = [String(other.err)];
106
- }
107
- } else {
108
- klass = "Unknown";
109
- }
110
-
111
- const title = `Failed to load config file ${id}, ${klass} error:`;
112
- let message: string;
113
- switch (messages?.length ?? 0) {
114
- case 0:
115
- message = title.slice(0, -1);
116
- break;
117
- case 1:
118
- message = `${title} ${messages![0]}`;
119
- break;
120
- default:
121
- message = `${title}\n${messages!.map(m => ` - ${m}`).join("\n")}`;
122
- break;
123
- }
124
-
125
- super(message, { cause });
126
- this.name = "LoadError";
127
- this.#message = message;
128
- }
129
-
130
- get message(): string {
131
- return this.#message;
132
- }
133
-
134
- toString(): string {
135
- return this.message;
136
- }
137
- }
138
-
139
- export type LoadStatus = "ok" | "error" | "not-found";
140
-
141
- export type LoadResult<T> =
142
- | { value?: null; error: ConfigError; status: "error" }
143
- | { value: T; error?: undefined; status: "ok" }
144
- | { value?: null; error?: unknown; status: "not-found" };
145
-
146
- export class ConfigFile<T> implements IConfigFile<T> {
147
- readonly #basePath: string;
148
- #cache?: LoadResult<T>;
149
- #auxValidate?: (value: T) => void;
150
-
151
- constructor(
152
- readonly id: string,
153
- readonly schema: TSchema,
154
- configPath: string = path.join(getAgentDir(), `${id}.yml`),
155
- ) {
156
- this.#basePath = configPath;
157
- if (configPath.endsWith(".yml")) {
158
- const jsonPath = `${configPath.slice(0, -4)}.json`;
159
- migrateJsonToYml(jsonPath, configPath);
160
- } else if (configPath.endsWith(".yaml")) {
161
- const jsonPath = `${configPath.slice(0, -5)}.json`;
162
- migrateJsonToYml(jsonPath, configPath);
163
- } else if (configPath.endsWith(".json") || configPath.endsWith(".jsonc")) {
164
- // JSON configs are still supported without migration.
165
- } else {
166
- throw new Error(`Invalid config file path: ${configPath}`);
167
- }
168
- }
169
-
170
- relocate(path?: string): ConfigFile<T> {
171
- if (!path || path === this.#basePath) return this;
172
- const result = new ConfigFile<T>(this.id, this.schema, path);
173
- result.#auxValidate = this.#auxValidate;
174
- return result;
175
- }
176
-
177
- getMtimeMs(): number | null {
178
- try {
179
- return fs.statSync(this.path()).mtimeMs;
180
- } catch (err) {
181
- if (isEnoent(err)) return null;
182
- throw err;
183
- }
184
- }
185
-
186
- withValidation(name: string, validate: (value: T) => void): this {
187
- const prev = this.#auxValidate;
188
- this.#auxValidate = (value: T) => {
189
- prev?.(value);
190
- try {
191
- validate(value);
192
- } catch (error) {
193
- throw new ConfigError(this.id, undefined, { err: error, stage: `Validate(${name})` });
194
- }
195
- };
196
- return this;
197
- }
198
-
199
- createDefault() {
200
- return Value.Default(this.schema, [], undefined) as T;
201
- }
202
-
203
- #storeCache(result: LoadResult<T>): LoadResult<T> {
204
- this.#cache = result;
205
- return result;
206
- }
207
-
208
- tryLoad(): LoadResult<T> {
209
- if (this.#cache) return this.#cache;
210
-
211
- try {
212
- const content = fs.readFileSync(this.path(), "utf-8").trim();
213
-
214
- let parsed: unknown;
215
- if (this.#basePath.endsWith(".json") || this.#basePath.endsWith(".jsonc")) {
216
- parsed = JSONC.parse(content);
217
- } else if (this.#basePath.endsWith(".yml") || this.#basePath.endsWith(".yaml")) {
218
- parsed = YAML.parse(content);
219
- } else {
220
- throw new Error(`Invalid config file path: ${this.#basePath}`);
221
- }
222
-
223
- if (!Value.Check(this.schema, parsed)) {
224
- const schemaErrors: ErrorObject[] = [];
225
- for (const err of Value.Errors(this.schema, parsed)) {
226
- schemaErrors.push({ instancePath: err.path, message: err.message } as ErrorObject);
227
- if (schemaErrors.length >= 50) break;
228
- }
229
- const error = new ConfigError(this.id, schemaErrors);
230
- logger.warn("Failed to parse config file", { path: this.path(), error });
231
- return this.#storeCache({ error, status: "error" });
232
- }
233
- return this.#storeCache({ value: parsed as T, status: "ok" });
234
- } catch (error) {
235
- if (isEnoent(error)) {
236
- return this.#storeCache({ status: "not-found" });
237
- }
238
- logger.warn("Failed to parse config file", { path: this.path(), error });
239
- return this.#storeCache({
240
- error: new ConfigError(this.id, undefined, { err: error, stage: "Unexpected" }),
241
- status: "error",
242
- });
243
- }
244
- }
245
-
246
- load(): T | null {
247
- return this.tryLoad().value ?? null;
248
- }
249
-
250
- loadOrDefault(): T {
251
- return this.tryLoad().value ?? this.createDefault();
252
- }
253
-
254
- path(): string {
255
- return this.#basePath;
256
- }
257
-
258
- invalidate() {
259
- this.#cache = undefined;
260
- }
261
- }
262
-
263
47
  // =============================================================================
264
48
  // Multi-Config Directory Helpers
265
49
  // =============================================================================
@@ -340,7 +340,13 @@ function normalizeHashlineInputPreviewPath(rawPath: string): string {
340
340
 
341
341
  function parseHashlineInputPreviewHeader(line: string): string | null {
342
342
  if (!line.startsWith(HL_INPUT_HEADER_PREFIX)) return null;
343
- const body = line.slice(HL_INPUT_HEADER_PREFIX.length).trim();
343
+ // The real parser (`parseHashlineHeaderLine` in `hashline/input.ts`) strips
344
+ // every leading "@" before resolving the path so canonical "@@ PATH" headers
345
+ // (and stray "@ PATH" / "@@@ PATH" runs) all route to the same file. Mirror
346
+ // that here so the renderer doesn't surface a literal "@ " in the title.
347
+ let prefixEnd = 0;
348
+ while (prefixEnd < line.length && line[prefixEnd] === HL_INPUT_HEADER_PREFIX) prefixEnd++;
349
+ const body = line.slice(prefixEnd).trim();
344
350
  const previewPath = normalizeHashlineInputPreviewPath(body);
345
351
  return previewPath.length > 0 ? previewPath : null;
346
352
  }
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_MAX_BYTES, OutputSink } from "../../session/streaming-output";
2
2
  import type { ToolSession } from "../../tools";
3
+ import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta";
3
4
  import { executeInVmContext, type JsDisplayOutput } from "./context-manager";
4
5
 
5
6
  export interface JsExecutorOptions {
@@ -49,6 +50,8 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
49
50
  artifactPath: options.artifactPath,
50
51
  artifactId: options.artifactId,
51
52
  spillThreshold: DEFAULT_MAX_BYTES,
53
+ headBytes: resolveOutputSinkHeadBytes(options.session.settings),
54
+ maxColumns: resolveOutputMaxColumns(options.session.settings),
52
55
  onChunk: chunk => options.onChunk?.(chunk),
53
56
  });
54
57
  const timeoutMs = getExecutionTimeoutMs(options);