@oh-my-pi/pi-coding-agent 16.2.12 → 16.3.0

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 (171) hide show
  1. package/CHANGELOG.md +115 -7
  2. package/dist/cli.js +5996 -5945
  3. package/dist/types/advisor/config.d.ts +4 -2
  4. package/dist/types/collab/host.d.ts +16 -0
  5. package/dist/types/collab/protocol.d.ts +9 -3
  6. package/dist/types/commit/model-selection.d.ts +5 -0
  7. package/dist/types/config/model-resolver.d.ts +12 -10
  8. package/dist/types/config/models-config-schema.d.ts +12 -0
  9. package/dist/types/config/models-config.d.ts +9 -0
  10. package/dist/types/config/settings-schema.d.ts +28 -5
  11. package/dist/types/edit/modes/patch.d.ts +11 -0
  12. package/dist/types/eval/agent-bridge.d.ts +5 -1
  13. package/dist/types/exec/bash-executor.d.ts +1 -0
  14. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +86 -2
  15. package/dist/types/extensibility/skills.d.ts +2 -1
  16. package/dist/types/mnemopi/state.d.ts +12 -0
  17. package/dist/types/modes/components/agent-hub.d.ts +9 -5
  18. package/dist/types/modes/components/status-line/component.test.d.ts +1 -0
  19. package/dist/types/modes/components/usage-row.d.ts +1 -1
  20. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  21. package/dist/types/modes/controllers/extension-ui-controller.d.ts +6 -0
  22. package/dist/types/modes/controllers/streaming-reveal.d.ts +17 -1
  23. package/dist/types/modes/controllers/tool-args-reveal.d.ts +30 -3
  24. package/dist/types/modes/interactive-mode.d.ts +12 -7
  25. package/dist/types/modes/rpc/rpc-client.d.ts +5 -0
  26. package/dist/types/modes/rpc/rpc-mode.d.ts +64 -1
  27. package/dist/types/modes/session-teardown.d.ts +63 -0
  28. package/dist/types/modes/session-teardown.test.d.ts +1 -0
  29. package/dist/types/modes/theme/theme.d.ts +9 -4
  30. package/dist/types/modes/types.d.ts +2 -3
  31. package/dist/types/modes/utils/copy-targets.d.ts +2 -0
  32. package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +1 -0
  33. package/dist/types/sdk.d.ts +9 -0
  34. package/dist/types/session/agent-session.d.ts +46 -13
  35. package/dist/types/session/exit-diagnostics.d.ts +48 -0
  36. package/dist/types/session/session-loader.d.ts +5 -0
  37. package/dist/types/task/executor.d.ts +12 -5
  38. package/dist/types/task/parallel.d.ts +1 -0
  39. package/dist/types/task/render.test.d.ts +1 -0
  40. package/dist/types/thinking.d.ts +2 -0
  41. package/dist/types/tools/checkpoint.d.ts +8 -0
  42. package/dist/types/tools/eval-render.d.ts +1 -0
  43. package/dist/types/tools/fetch.d.ts +11 -1
  44. package/dist/types/tools/index.d.ts +3 -1
  45. package/dist/types/tools/irc.d.ts +1 -0
  46. package/dist/types/tools/output-meta.d.ts +7 -0
  47. package/dist/types/tools/output-schema-validator.d.ts +7 -4
  48. package/dist/types/tools/path-utils.d.ts +16 -0
  49. package/dist/types/tools/render-utils.d.ts +4 -1
  50. package/dist/types/utils/git.d.ts +47 -2
  51. package/dist/types/web/search/provider.d.ts +7 -0
  52. package/dist/types/web/search/types.d.ts +1 -1
  53. package/package.json +12 -12
  54. package/src/advisor/config.ts +4 -2
  55. package/src/cli/bench-cli.ts +7 -2
  56. package/src/collab/guest.ts +94 -5
  57. package/src/collab/host.ts +80 -3
  58. package/src/collab/protocol.ts +9 -2
  59. package/src/commit/model-selection.ts +8 -2
  60. package/src/config/keybindings.ts +3 -1
  61. package/src/config/model-discovery.ts +66 -2
  62. package/src/config/model-registry.ts +7 -3
  63. package/src/config/model-resolver.ts +51 -23
  64. package/src/config/models-config-schema.ts +13 -0
  65. package/src/config/settings-schema.ts +44 -14
  66. package/src/edit/hashline/diff.ts +13 -2
  67. package/src/edit/index.ts +58 -8
  68. package/src/edit/modes/patch.ts +53 -18
  69. package/src/edit/streaming.ts +7 -6
  70. package/src/eval/__tests__/agent-bridge.test.ts +57 -1
  71. package/src/eval/agent-bridge.ts +15 -5
  72. package/src/exec/bash-executor.ts +7 -12
  73. package/src/extensibility/legacy-pi-coding-agent-shim.ts +534 -16
  74. package/src/extensibility/skills.ts +29 -6
  75. package/src/goals/guided-setup.ts +4 -3
  76. package/src/internal-urls/docs-index.generated.txt +1 -1
  77. package/src/lsp/client.ts +11 -14
  78. package/src/main.ts +38 -10
  79. package/src/mnemopi/state.ts +43 -7
  80. package/src/modes/acp/acp-agent.ts +1 -1
  81. package/src/modes/components/agent-hub.ts +10 -2
  82. package/src/modes/components/agent-transcript-viewer.ts +39 -7
  83. package/src/modes/components/assistant-message.ts +16 -10
  84. package/src/modes/components/chat-transcript-builder.ts +11 -1
  85. package/src/modes/components/custom-editor.test.ts +20 -0
  86. package/src/modes/components/hook-selector.ts +44 -25
  87. package/src/modes/components/model-selector.ts +1 -1
  88. package/src/modes/components/status-line/component.test.ts +44 -0
  89. package/src/modes/components/status-line/component.ts +9 -1
  90. package/src/modes/components/status-line/segments.ts +3 -3
  91. package/src/modes/components/usage-row.ts +16 -2
  92. package/src/modes/controllers/command-controller.ts +6 -21
  93. package/src/modes/controllers/event-controller.ts +11 -9
  94. package/src/modes/controllers/extension-ui-controller.ts +84 -3
  95. package/src/modes/controllers/input-controller.ts +16 -13
  96. package/src/modes/controllers/selector-controller.ts +3 -8
  97. package/src/modes/controllers/streaming-reveal.ts +75 -53
  98. package/src/modes/controllers/tool-args-reveal.ts +340 -10
  99. package/src/modes/interactive-mode.ts +122 -79
  100. package/src/modes/rpc/rpc-client.ts +21 -0
  101. package/src/modes/rpc/rpc-mode.ts +197 -46
  102. package/src/modes/session-teardown.test.ts +219 -0
  103. package/src/modes/session-teardown.ts +82 -0
  104. package/src/modes/setup-wizard/scenes/theme.ts +2 -2
  105. package/src/modes/skill-command.ts +7 -20
  106. package/src/modes/theme/theme.ts +29 -15
  107. package/src/modes/types.ts +2 -3
  108. package/src/modes/utils/copy-targets.ts +12 -0
  109. package/src/modes/utils/ui-helpers.ts +19 -2
  110. package/src/plan-mode/approved-plan-prompt.test.ts +36 -0
  111. package/src/prompts/advisor/system.md +1 -1
  112. package/src/prompts/agents/tester.md +6 -2
  113. package/src/prompts/skills/autoload.md +8 -0
  114. package/src/prompts/skills/user-invocation.md +11 -0
  115. package/src/prompts/steering/parent-irc.md +5 -0
  116. package/src/prompts/system/irc-incoming.md +2 -0
  117. package/src/prompts/system/mid-run-todo-nudge.md +3 -0
  118. package/src/prompts/system/plan-mode-approved.md +7 -10
  119. package/src/prompts/system/plan-mode-compact-instructions.md +2 -1
  120. package/src/prompts/system/plan-mode-reference.md +3 -4
  121. package/src/prompts/system/rewind-report.md +6 -0
  122. package/src/prompts/system/subagent-system-prompt.md +3 -0
  123. package/src/prompts/tools/irc.md +2 -1
  124. package/src/prompts/tools/job.md +2 -2
  125. package/src/prompts/tools/rewind.md +3 -2
  126. package/src/prompts/tools/ssh.md +1 -0
  127. package/src/prompts/tools/task.md +4 -0
  128. package/src/sdk.ts +18 -4
  129. package/src/session/agent-session.ts +660 -114
  130. package/src/session/exit-diagnostics.ts +202 -0
  131. package/src/session/session-context.ts +25 -13
  132. package/src/session/session-loader.ts +58 -25
  133. package/src/session/session-manager.ts +0 -1
  134. package/src/session/session-persistence.ts +30 -4
  135. package/src/session/settings-stream-fn.ts +14 -0
  136. package/src/slash-commands/builtin-registry.ts +35 -3
  137. package/src/system-prompt.ts +15 -1
  138. package/src/task/executor.ts +31 -8
  139. package/src/task/index.ts +31 -9
  140. package/src/task/isolation-runner.ts +18 -2
  141. package/src/task/parallel.ts +7 -2
  142. package/src/task/render.test.ts +121 -0
  143. package/src/task/render.ts +48 -2
  144. package/src/task/worktree.ts +12 -13
  145. package/src/task/yield-assembly.ts +8 -5
  146. package/src/thinking.ts +5 -0
  147. package/src/tools/ask.ts +188 -9
  148. package/src/tools/ast-edit.ts +7 -0
  149. package/src/tools/ast-grep.ts +11 -0
  150. package/src/tools/bash.ts +6 -30
  151. package/src/tools/checkpoint.ts +15 -1
  152. package/src/tools/eval-render.ts +15 -0
  153. package/src/tools/fetch.ts +82 -18
  154. package/src/tools/gh.ts +1 -1
  155. package/src/tools/grep.ts +45 -27
  156. package/src/tools/index.ts +3 -1
  157. package/src/tools/irc.ts +24 -9
  158. package/src/tools/output-meta.ts +50 -0
  159. package/src/tools/output-schema-validator.ts +152 -15
  160. package/src/tools/path-utils.ts +55 -10
  161. package/src/tools/read.ts +1 -1
  162. package/src/tools/render-utils.ts +5 -3
  163. package/src/tools/ssh.ts +8 -1
  164. package/src/tools/yield.ts +41 -1
  165. package/src/utils/commit-message-generator.ts +2 -2
  166. package/src/utils/git.ts +271 -29
  167. package/src/utils/thinking-display.ts +13 -0
  168. package/src/web/search/index.ts +9 -28
  169. package/src/web/search/provider.ts +26 -1
  170. package/src/web/search/providers/duckduckgo.ts +1 -1
  171. package/src/web/search/types.ts +5 -1
@@ -8,11 +8,13 @@
8
8
  * cannot be rejected post-mortem (or vice versa).
9
9
  */
10
10
  import {
11
+ dereferenceJsonSchema,
11
12
  isValidJsonSchema,
12
13
  type JsonSchemaValidationIssue,
13
14
  type JsonSchemaValidationResult,
14
15
  validateJsonSchemaValue,
15
16
  } from "@oh-my-pi/pi-ai/utils/schema";
17
+ import { isRecord } from "@oh-my-pi/pi-utils";
16
18
  import { jtdToJsonSchema, normalizeSchema } from "./jtd-to-json-schema";
17
19
 
18
20
  /** A validator bound to a specific output schema. */
@@ -25,12 +27,15 @@ export interface OutputValidator {
25
27
  * Per-label validators for incremental yields (`type: ["<label>"]`). Each entry validates the
26
28
  * `data` payload of a single section against the matching top-level property's sub-schema —
27
29
  * array-typed properties (e.g. `findings`) use the items schema since each yield contributes
28
- * one element, while scalar properties use the property schema directly. Unknown labels (not
29
- * top-level properties) have no entry and skip per-call validation. Lets the yield tool give
30
- * the model retry feedback on a section as soon as it arrives, instead of deferring every
31
- * mismatch to the parent's post-mortem `schema_violation`.
30
+ * one element, while scalar properties use the property schema directly.
32
31
  */
33
32
  readonly validateSection: ReadonlyMap<string, (value: unknown) => JsonSchemaValidationResult>;
33
+ /** Whether top-level schema closure makes unknown incremental yield labels invalid. */
34
+ readonly rejectUnknownSections: boolean;
35
+ /** Finite top-level section labels declared directly by the schema. Pattern-backed labels are accepted via `isKnownSection`. */
36
+ readonly knownSectionLabels: readonly string[];
37
+ /** Whether an incremental yield label is accepted by the top-level schema declaration. */
38
+ isKnownSection(label: string): boolean;
34
39
  }
35
40
 
36
41
  export interface BuildOutputValidatorResult {
@@ -75,14 +80,28 @@ export function buildOutputValidator(schema: unknown): BuildOutputValidatorResul
75
80
  if (!isValidJsonSchema(jsonSchema)) return { error: "invalid JSON schema", normalized };
76
81
 
77
82
  const jsonSchemaRecord = jsonSchema as Record<string, unknown>;
78
- const required = extractRequiredFields(jsonSchemaRecord);
83
+ // Resolve a root `$ref` (e.g. caller schemas exported as `{ $ref: "#/$defs/Closed", $defs: ... }`)
84
+ // before deriving incremental-label metadata. AJV-style validation chases the ref at runtime, so
85
+ // `validate()` accepts the resolved object — but `properties` and `additionalProperties` live on
86
+ // the inlined node, not the wrapper. Without this, unknown labels slipped past the yield gate and
87
+ // only fired as parent-side schema_violations.
88
+ const dereferenced = dereferenceJsonSchema(jsonSchemaRecord);
89
+ const labelSchema =
90
+ dereferenced && typeof dereferenced === "object" && !Array.isArray(dereferenced)
91
+ ? (dereferenced as Record<string, unknown>)
92
+ : jsonSchemaRecord;
93
+ const required = extractRequiredFields(labelSchema);
94
+ const sectionLabels = buildSectionLabelMetadata(labelSchema);
79
95
  return {
80
96
  normalized,
81
97
  jsonSchema: jsonSchemaRecord,
82
98
  validator: {
83
99
  requiredFields: required,
84
100
  validate: value => validateJsonSchemaValue(jsonSchemaRecord, value),
85
- validateSection: buildSectionValidators(jsonSchemaRecord),
101
+ validateSection: buildSectionValidators(labelSchema),
102
+ rejectUnknownSections: sectionLabels.rejectUnknownSections,
103
+ knownSectionLabels: sectionLabels.labels,
104
+ isKnownSection: sectionLabels.isKnown,
86
105
  },
87
106
  };
88
107
  }
@@ -93,27 +112,145 @@ export function buildOutputValidator(schema: unknown): BuildOutputValidatorResul
93
112
  * Each entry validates the `data` payload of one `type: ["<label>"]` section against the
94
113
  * matching property's sub-schema — array-typed properties (e.g. `findings`, derived from JTD
95
114
  * `elements`) use the items schema since each yield contributes one element, while scalar
96
- * properties use the property schema directly. Unknown labels (anything not declared as a
97
- * top-level property) are deliberately omitted so user-defined section labels still pass.
115
+ * properties use the property schema directly. Closed top-level schemas reject labels that are
116
+ * not declared as properties.
98
117
  */
99
118
  function buildSectionValidators(
100
119
  jsonSchema: Record<string, unknown>,
101
120
  ): ReadonlyMap<string, (value: unknown) => JsonSchemaValidationResult> {
102
121
  const validators = new Map<string, (value: unknown) => JsonSchemaValidationResult>();
103
122
  const properties = jsonSchema.properties;
104
- if (properties === null || typeof properties !== "object") return validators;
105
- for (const [label, raw] of Object.entries(properties as Record<string, unknown>)) {
106
- if (raw === null || typeof raw !== "object") continue;
107
- const propRecord = raw as Record<string, unknown>;
123
+ if (!isRecord(properties)) return validators;
124
+ for (const label in properties) {
125
+ const raw = properties[label];
126
+ const propRecord = isRecord(raw) ? raw : undefined;
108
127
  const sectionSchema =
109
- propRecord.type === "array" && propRecord.items !== undefined && propRecord.items !== null
110
- ? (propRecord.items as Record<string, unknown>)
111
- : propRecord;
128
+ propRecord?.type === "array" && propRecord.items !== undefined && propRecord.items !== null
129
+ ? propRecord.items
130
+ : raw;
112
131
  validators.set(label, value => validateJsonSchemaValue(sectionSchema, value));
113
132
  }
114
133
  return validators;
115
134
  }
116
135
 
136
+ interface SectionLabelMetadata {
137
+ readonly labels: readonly string[];
138
+ readonly rejectUnknownSections: boolean;
139
+ isKnown(label: string): boolean;
140
+ }
141
+
142
+ /**
143
+ * Derive incremental-label metadata from top-level schema closure.
144
+ *
145
+ * The unknown-label gate (`rejectUnknownSections`) engages when the schema constrains top-level
146
+ * property names anywhere: a closed conjunct (root or recursive `allOf` child with
147
+ * `additionalProperties: false`) or a `oneOf`/`anyOf` union whose EVERY variant is closed. A label
148
+ * is known iff every closed conjunct accepts it AND, per closed union, at least one variant
149
+ * accepts it (union semantics are disjunctive — the assembled output only has to match one
150
+ * variant). Unions containing any open variant never gate: the open variant accepts arbitrary
151
+ * labels, so rejection would be a false positive.
152
+ */
153
+ function buildSectionLabelMetadata(jsonSchema: Record<string, unknown>): SectionLabelMetadata {
154
+ const closedConjuncts = collectClosedTopLevelSchemas(jsonSchema);
155
+ const closedUnions = collectClosedTopLevelUnions(jsonSchema);
156
+ const closed = closedConjuncts.length > 0 || closedUnions.length > 0;
157
+ const acceptedByAll = (conjuncts: readonly Record<string, unknown>[], label: string): boolean =>
158
+ conjuncts.every(schema => schemaAcceptsSectionLabel(schema, label));
159
+ const labels = [
160
+ ...new Set([
161
+ ...closedConjuncts.flatMap(schema => declaredPropertyLabels(schema)),
162
+ ...closedUnions.flatMap(variants =>
163
+ variants.flatMap(conjuncts => conjuncts.flatMap(schema => declaredPropertyLabels(schema))),
164
+ ),
165
+ ]),
166
+ ];
167
+ return {
168
+ labels,
169
+ rejectUnknownSections: closed,
170
+ isKnown: label =>
171
+ !closed ||
172
+ (acceptedByAll(closedConjuncts, label) &&
173
+ closedUnions.every(variants => variants.some(conjuncts => acceptedByAll(conjuncts, label)))),
174
+ };
175
+ }
176
+
177
+ function collectClosedTopLevelSchemas(jsonSchema: Record<string, unknown>): Record<string, unknown>[] {
178
+ const schemas: Record<string, unknown>[] = [];
179
+ if (jsonSchema.additionalProperties === false) schemas.push(jsonSchema);
180
+ const allOf = jsonSchema.allOf;
181
+ if (Array.isArray(allOf)) {
182
+ for (const raw of allOf) {
183
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
184
+ schemas.push(...collectClosedTopLevelSchemas(raw as Record<string, unknown>));
185
+ }
186
+ }
187
+ }
188
+ return schemas;
189
+ }
190
+
191
+ /** One fully-closed `oneOf`/`anyOf` union: per variant, that variant's closed conjunct schemas. */
192
+ type ClosedUnionVariants = Record<string, unknown>[][];
193
+
194
+ /**
195
+ * Collect top-level `oneOf`/`anyOf` unions in which EVERY variant is closed — i.e. each variant
196
+ * (or one of its `allOf` conjuncts, resolved via `collectClosedTopLevelSchemas`) carries
197
+ * `additionalProperties: false`. JTD discriminator output schemas compile to exactly this shape:
198
+ * a root `oneOf` of closed object variants. Unions with any open (or non-object) variant are
199
+ * skipped entirely so the unknown-label gate cannot fire false rejections. Unions nested under
200
+ * `allOf` conjuncts gate identically (intersection semantics).
201
+ */
202
+ function collectClosedTopLevelUnions(jsonSchema: Record<string, unknown>): ClosedUnionVariants[] {
203
+ const unions: ClosedUnionVariants[] = [];
204
+ for (const key of ["oneOf", "anyOf"] as const) {
205
+ const rawVariants = jsonSchema[key];
206
+ if (!Array.isArray(rawVariants) || rawVariants.length === 0) continue;
207
+ const variants: ClosedUnionVariants = [];
208
+ let allClosed = true;
209
+ for (const raw of rawVariants) {
210
+ const conjuncts = isRecord(raw) ? collectClosedTopLevelSchemas(raw) : [];
211
+ if (conjuncts.length === 0) {
212
+ allClosed = false;
213
+ break;
214
+ }
215
+ variants.push(conjuncts);
216
+ }
217
+ if (allClosed) unions.push(variants);
218
+ }
219
+ const allOf = jsonSchema.allOf;
220
+ if (Array.isArray(allOf)) {
221
+ for (const raw of allOf) {
222
+ if (isRecord(raw)) unions.push(...collectClosedTopLevelUnions(raw));
223
+ }
224
+ }
225
+ return unions;
226
+ }
227
+
228
+ function declaredPropertyLabels(jsonSchema: Record<string, unknown>): string[] {
229
+ const properties = jsonSchema.properties;
230
+ if (properties === null || typeof properties !== "object" || Array.isArray(properties)) return [];
231
+ const labels: string[] = [];
232
+ for (const label in properties) labels.push(label);
233
+ return labels;
234
+ }
235
+
236
+ function schemaAcceptsSectionLabel(jsonSchema: Record<string, unknown>, label: string): boolean {
237
+ const properties = jsonSchema.properties;
238
+ if (properties !== null && typeof properties === "object" && !Array.isArray(properties) && label in properties) {
239
+ return true;
240
+ }
241
+ const patternProperties = jsonSchema.patternProperties;
242
+ if (patternProperties !== null && typeof patternProperties === "object" && !Array.isArray(patternProperties)) {
243
+ for (const pattern in patternProperties) {
244
+ try {
245
+ if (new RegExp(pattern).test(label)) return true;
246
+ } catch {
247
+ // `isValidJsonSchema` already rejected malformed regexes; ignore any unexpected runtime mismatch.
248
+ }
249
+ }
250
+ }
251
+ return jsonSchema.additionalProperties !== false;
252
+ }
253
+
117
254
  /** Produce the executor's headline+missing-required summary from a failed validation. */
118
255
  export function summarizeValidationFailure(
119
256
  result: JsonSchemaValidationResult,
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import * as url from "node:url";
5
- import { isEnoent, stripWindowsExtendedLengthPathPrefix } from "@oh-my-pi/pi-utils";
5
+ import { isEnoent, isEnotdir, stripWindowsExtendedLengthPathPrefix } from "@oh-my-pi/pi-utils";
6
6
  import type { Skill } from "../extensibility/skills";
7
7
  import { InternalUrlRouter, type LocalProtocolOptions } from "../internal-urls";
8
8
  import { ToolError } from "./tool-errors";
@@ -435,6 +435,16 @@ export function isSshUrl(path: string): boolean {
435
435
  return /^ssh:\/\//i.test(path.trim());
436
436
  }
437
437
 
438
+ /**
439
+ * True when the read tool's URL parser (`parseReadUrlTarget` in fetch.ts) would
440
+ * recognize this path as a readable external URL: a strict `http(s)://`, a
441
+ * collapsed `http(s):/host` (Node path normalization folds `//` → `/`), or a
442
+ * scheme-less `www.` spelling. Keep in sync with `parseReadUrlTarget`.
443
+ */
444
+ export function isReadableUrlPath(value: string): boolean {
445
+ return /^https?:\/\/?/i.test(value) || /^www\./i.test(value);
446
+ }
447
+
438
448
  /**
439
449
  * Resolve a path relative to the given cwd.
440
450
  * Handles ~ expansion and absolute paths.
@@ -1026,6 +1036,14 @@ export function resolveReadPath(filePath: string, cwd: string): string {
1026
1036
  // Tool-scope resolution (search/ast tools)
1027
1037
  // =============================================================================
1028
1038
 
1039
+ /** Local file materialized from a readable external URL for shared tool-scope resolution. */
1040
+ export interface ResolvedExternalSearchUrl {
1041
+ /** Absolute or cwd-relative file path to search. */
1042
+ sourcePath: string;
1043
+ /** True when the materialized file must not mint editable anchors. */
1044
+ immutable?: boolean;
1045
+ }
1046
+
1029
1047
  export interface ToolScopeOptions {
1030
1048
  rawPaths: string[];
1031
1049
  cwd: string;
@@ -1049,6 +1067,8 @@ export interface ToolScopeOptions {
1049
1067
  localProtocolOptions?: LocalProtocolOptions;
1050
1068
  /** Calling session's loaded skills — lets skill:// resolve without process-global state. */
1051
1069
  skills?: readonly Skill[];
1070
+ /** Materialize readable external URLs to local text files before scope derivation. */
1071
+ resolveExternalUrl?: (rawPath: string) => Promise<ResolvedExternalSearchUrl | undefined>;
1052
1072
  }
1053
1073
 
1054
1074
  export interface ToolScopeResolution {
@@ -1080,19 +1100,44 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
1080
1100
  if (rawPaths.some(rawPath => rawPath.length === 0)) {
1081
1101
  throw new ToolError("`paths` must contain non-empty paths or globs");
1082
1102
  }
1083
- // External (http/https/ftp/file) URLs are not searchable; route the caller
1084
- // to `read` instead of letting the path-resolver surface a confusing
1085
- // "Path not found" for a slash-stripped URL.
1086
- const externalUrl = rawPaths.find(rawPath => /^(?:https?|ftp|file|ws|wss):\/\//i.test(rawPath));
1087
- if (externalUrl) {
1088
- throw new ToolError(
1089
- `Cannot ${internalUrlAction} external URL: ${externalUrl}. Use \`read\` to fetch web content, then search the returned text.`,
1090
- );
1091
- }
1103
+ // Strict external-URL schemes. `file://` is intentionally absent: it has
1104
+ // local-path semantics (expandPath strips it downstream), so it flows through
1105
+ // the ordinary filesystem pipeline instead of the external-URL resolver.
1106
+ const strictExternalUrlRe = /^(?:https?|ftp|ws|wss):\/\//i;
1092
1107
  const internalRouter = InternalUrlRouter.instance();
1093
1108
  const resolvedPathInputs: string[] = [];
1094
1109
  const immutableSourcePaths = new Set<string>();
1095
1110
  for (const rawPath of rawPaths) {
1111
+ let externalUrl = strictExternalUrlRe.test(rawPath);
1112
+ if (!externalUrl && isReadableUrlPath(rawPath) && !hasGlobPathChars(rawPath)) {
1113
+ // Fuzzy spelling the read parser accepts (`www.host/…`, collapsed
1114
+ // `https:/host/…`). An existing local path wins over URL
1115
+ // interpretation so a directory literally named `www.foo` stays
1116
+ // searchable; only a definitive ENOENT/ENOTDIR flips to URL handling
1117
+ // (any other stat error means the path exists — let the local
1118
+ // pipeline surface it).
1119
+ try {
1120
+ await fs.promises.stat(resolveToCwd(rawPath, cwd));
1121
+ } catch (err) {
1122
+ externalUrl = isEnoent(err) || isEnotdir(err);
1123
+ }
1124
+ }
1125
+ if (externalUrl) {
1126
+ const resolved = opts.resolveExternalUrl ? await opts.resolveExternalUrl(rawPath) : undefined;
1127
+ if (resolved) {
1128
+ resolvedPathInputs.push(resolved.sourcePath);
1129
+ if (opts.trackImmutableSources && resolved.immutable) {
1130
+ immutableSourcePaths.add(path.resolve(resolved.sourcePath));
1131
+ }
1132
+ continue;
1133
+ }
1134
+ // Resolver missing or declined (e.g. ftp/ws/wss): fail explicitly
1135
+ // instead of letting the local-path fallthrough surface a confusing
1136
+ // "Path not found" for a URL-shaped input.
1137
+ throw new ToolError(
1138
+ `Cannot ${internalUrlAction} external URL: ${rawPath}. Use \`read\` to fetch web content, then search the returned text.`,
1139
+ );
1140
+ }
1096
1141
  if (!internalRouter.canHandle(rawPath)) {
1097
1142
  resolvedPathInputs.push(rawPath);
1098
1143
  continue;
package/src/tools/read.ts CHANGED
@@ -77,7 +77,6 @@ import {
77
77
  } from "./conflict-detect";
78
78
  import {
79
79
  executeReadUrl,
80
- isReadableUrlPath,
81
80
  loadReadUrlCacheEntry,
82
81
  parseReadUrlTarget,
83
82
  type ReadUrlToolDetails,
@@ -95,6 +94,7 @@ import {
95
94
  import {
96
95
  expandPath,
97
96
  formatPathRelativeToCwd,
97
+ isReadableUrlPath,
98
98
  type LineRange,
99
99
  parseLineRanges,
100
100
  pathTargetsSsh,
@@ -222,19 +222,21 @@ export function previewWindowRows(): number {
222
222
  * (ctrl+o) uncaps it.
223
223
  *
224
224
  * `prefix` (raw, e.g. a dim tree gutter) is prepended to the marker line so
225
- * nested previews stay aligned.
225
+ * nested previews stay aligned. `expandHint: false` drops the "ctrl+o: Expand"
226
+ * suffix for callers that cap even inside the expanded view (task recent
227
+ * output), where the hint would point the wrong way.
226
228
  */
227
229
  export function capPreviewLines(
228
230
  lines: string[],
229
231
  theme: Theme,
230
- options: { max?: number; expanded?: boolean; prefix?: string } = {},
232
+ options: { max?: number; expanded?: boolean; prefix?: string; expandHint?: boolean } = {},
231
233
  ): string[] {
232
234
  if (options.expanded) return lines;
233
235
  const max = options.max ?? previewWindowRows();
234
236
  if (lines.length <= max) return lines;
235
237
  const visible = max <= 1 ? [] : lines.slice(lines.length - (max - 1));
236
238
  const hidden = lines.length - visible.length;
237
- const hint = formatExpandHint(theme, false, true);
239
+ const hint = options.expandHint === false ? "" : formatExpandHint(theme, false, true);
238
240
  const marker = `… ${hidden} earlier ${pluralize("line", hidden)}${hint ? ` ${hint}` : ""}`;
239
241
  return [`${options.prefix ?? ""}${theme.fg("dim", marker)}`, ...visible];
240
242
  }
package/src/tools/ssh.ts CHANGED
@@ -26,7 +26,7 @@ import { clampTimeout } from "./tool-timeouts";
26
26
  const sshSchema = type({
27
27
  host: type("string").describe("ssh host"),
28
28
  command: type("string").describe("remote command"),
29
- "cwd?": type("string").describe("remote working directory"),
29
+ "cwd?": type("string").describe("remote working directory; omit unless required, never ~ or ~/..."),
30
30
  "timeout?": type("number").describe("timeout in seconds"),
31
31
  });
32
32
 
@@ -89,6 +89,12 @@ function quoteCmdPath(value: string): string {
89
89
  const escaped = value.replace(/"/g, '""');
90
90
  return `"${escaped}"`;
91
91
  }
92
+ function assertValidSshCwd(cwd: string | undefined): void {
93
+ if (!cwd) return;
94
+ if (cwd === "~" || cwd.startsWith("~/")) {
95
+ throw new ToolError("SSH cwd must be an absolute remote path; omit cwd instead of using ~.");
96
+ }
97
+ }
92
98
 
93
99
  function buildRemoteCommand(command: string, cwd: string | undefined, info: SSHHostInfo): string {
94
100
  if (!cwd) return command;
@@ -177,6 +183,7 @@ export class SshTool implements AgentTool<typeof sshSchema, SSHToolDetails> {
177
183
  if (!hostConfig) {
178
184
  throw new ToolError(`SSH host not loaded: ${host}`);
179
185
  }
186
+ assertValidSshCwd(cwd);
180
187
 
181
188
  const hostInfo = await ensureHostInfo(hostConfig);
182
189
  const remoteCommand = buildRemoteCommand(command, cwd, hostInfo);
@@ -213,11 +213,17 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
213
213
 
214
214
  readonly #validate?: (value: unknown) => JsonSchemaValidationResult;
215
215
  readonly #validateSection?: ReadonlyMap<string, (value: unknown) => JsonSchemaValidationResult>;
216
+ #rejectUnknownSections = false;
217
+ #knownSectionLabels: readonly string[] = [];
218
+ #isKnownSection?: (label: string) => boolean;
216
219
  #schemaValidationFailures = 0;
217
220
 
218
221
  constructor(session: ToolSession) {
219
222
  let validate: ((value: unknown) => JsonSchemaValidationResult) | undefined;
220
223
  let validateSection: ReadonlyMap<string, (value: unknown) => JsonSchemaValidationResult> | undefined;
224
+ let rejectUnknownSections = false;
225
+ let knownSectionLabels: readonly string[] = [];
226
+ let isKnownSection: ((label: string) => boolean) | undefined;
221
227
  let parameters: TSchema;
222
228
 
223
229
  try {
@@ -230,6 +236,9 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
230
236
  if (validator) {
231
237
  validate = value => validator.validate(value);
232
238
  validateSection = validator.validateSection;
239
+ rejectUnknownSections = validator.rejectUnknownSections;
240
+ knownSectionLabels = validator.knownSectionLabels;
241
+ isKnownSection = label => validator.isKnownSection(label);
233
242
  }
234
243
 
235
244
  const schemaHint = formatSchema(normalizedSchema ?? session.outputSchema);
@@ -280,6 +289,9 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
280
289
 
281
290
  this.#validate = validate;
282
291
  this.#validateSection = validateSection;
292
+ this.#rejectUnknownSections = rejectUnknownSections;
293
+ this.#knownSectionLabels = knownSectionLabels;
294
+ this.#isKnownSection = isKnownSection;
283
295
  this.parameters = parameters;
284
296
  }
285
297
 
@@ -317,6 +329,21 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
317
329
 
318
330
  const status = errorMessage !== undefined ? "aborted" : "success";
319
331
  let schemaValidationOverridden = false;
332
+ // Unknown incremental labels are a hard contract mismatch with the closed caller
333
+ // schema. Reject before the last-turn short-circuit too: `type: ["findings"], result: {}`
334
+ // would otherwise be accepted as a typed last-turn incremental yield, then a sibling
335
+ // section's MAX_SCHEMA_RETRIES override flips schemaOverridden in finalization and the
336
+ // stale section rides along untouched.
337
+ if (status === "success" && isIncremental) {
338
+ const unknownLabels = this.#unknownIncrementalLabels(yieldType as string[]);
339
+ if (unknownLabels.length > 0) {
340
+ const validLabels =
341
+ this.#knownSectionLabels.length > 0 ? formatYieldLabels(this.#knownSectionLabels) : "none";
342
+ throw new Error(
343
+ `Section ${formatYieldLabels(yieldType as string[])} uses unknown incremental yield label(s): ${formatYieldLabels(unknownLabels)}. Resubmit with one of the schema's labels: ${validLabels}.`,
344
+ );
345
+ }
346
+ }
320
347
  if (status === "success" && !useLastTurn) {
321
348
  if (data === null) {
322
349
  throw new Error("data is required when yield indicates success");
@@ -362,13 +389,26 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
362
389
  };
363
390
  }
364
391
 
392
+ /**
393
+ * Return incremental yield labels the closed caller schema does not accept. Closure covers the
394
+ * root, `allOf` conjuncts, and `oneOf`/`anyOf` unions whose every variant is closed (e.g. JTD
395
+ * discriminators). Open schemas accept any label.
396
+ */
397
+ #unknownIncrementalLabels(labels: string[]): string[] {
398
+ if (!this.#rejectUnknownSections) return [];
399
+ const isKnown = this.#isKnownSection;
400
+ if (!isKnown) return [];
401
+ return labels.filter(label => !isKnown(label));
402
+ }
403
+
365
404
  /**
366
405
  * Validate the `data` payload of an incremental yield (`type: ["<label>", …]`) against
367
406
  * the matching property's sub-validator. Returns the first failure across all known labels,
368
407
  * or `undefined` when no label is recognised (user-defined section labels stay loose) or
369
408
  * when all known labels accept the value. Lets the model see the same retry feedback that
370
409
  * the terminal-yield path already produces, instead of leaking the mismatch through to
371
- * the parent's post-mortem `schema_violation`.
410
+ * the parent's post-mortem `schema_violation`. Unknown labels under a closed schema are
411
+ * handled separately by `#unknownIncrementalLabels` and never reach this validator.
372
412
  */
373
413
  #validateIncrementalSection(labels: string[], data: unknown): JsonSchemaValidationResult | undefined {
374
414
  const subValidators = this.#validateSection;
@@ -12,7 +12,7 @@ import { getModelMatchPreferences, resolveModelRoleValue } from "../config/model
12
12
  import type { Settings } from "../config/settings";
13
13
  import MODEL_PRIO from "../priority.json" with { type: "json" };
14
14
  import commitSystemPrompt from "../prompts/system/commit-message-system.md" with { type: "text" };
15
- import { toReasoningEffort } from "../thinking";
15
+ import { concreteThinkingLevel, toReasoningEffort } from "../thinking";
16
16
 
17
17
  const COMMIT_SYSTEM_PROMPT = prompt.render(commitSystemPrompt);
18
18
  const MAX_DIFF_CHARS = 4000;
@@ -56,7 +56,7 @@ function getSmolModelCandidates(
56
56
  settings,
57
57
  matchPreferences,
58
58
  });
59
- addCandidate(configuredSmol.model, configuredSmol.thinkingLevel);
59
+ addCandidate(configuredSmol.model, concreteThinkingLevel(configuredSmol.thinkingLevel));
60
60
 
61
61
  for (const pattern of MODEL_PRIO.smol) {
62
62
  const needle = pattern.toLowerCase();