pi-freeflow 1.35.0 → 1.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Fix tool calls being rejected as invalid JSON on every model, and tool names coming back under a name the host never registered.
8
+
9
+ - A tool whose arguments arrived carrying the tail of the previous call is now repaired for **every** tool, not only tools declaring a nested object or array. The most-used tool, the shell command tool, declares nothing but scalars, so it was never repaired at all — the call was rejected and the turn looped on the same broken payload.
10
+ - Buffered tool arguments are released when a stream ends without its normal ending event, instead of being dropped: a truncated stream used to deliver a call with no arguments at all.
11
+ - The tool-name restore now runs on the Cline and OVH lanes too. Those lanes send a renamed tool upstream but never took it back, so a client received a call for a tool it does not have.
12
+ - A caller that declares both file-pattern and semantic-search tools now keeps both. The two share a name across hosts but are different tools, and collapsing them handed the model one tool under the other's shape.
13
+ - A truncated number is refused rather than completed, and an inner object is never promoted over the wrapper that declares it.
14
+ - A non-streaming answer from the Kilo lane is repaired like a streamed one.
15
+ - The free tier is the only lane that renames a tool now. The other lanes carry
16
+ the caller's own names in both directions, so the host dispatches natively
17
+ instead of waiting for a name that never comes back.
18
+ - Tools that declare an optional value as "value or nothing" are repaired
19
+ again. Both hosts describe optional settings that way when a tool is declared
20
+ strictly, and reading that as "always send it" silently switched repair off
21
+ for the shell command tool — the one that matters most.
22
+ - A model answering an optional setting with the word "none" as text, instead of
23
+ leaving it empty, is now corrected rather than rejected. Affects roughly one
24
+ model in nine.
25
+ - A streamed Anthropic-format answer is now repaired and mapped like the other
26
+ two formats. It was forwarded untouched.
27
+ - A stream that ends without an answer says so, instead of reporting an empty
28
+ successful one.
29
+
30
+ ## 1.36.0
31
+
32
+ ### Minor Changes
33
+
34
+ - bf358da: A new free model on the OpenCode Zen lane: Cat Jam
35
+
36
+ `cat-jam-free` joins the picker, bringing the catalog to 37 models (11 OpenCode, 15 Kilo, 2 Cline, 9 OVHcloud). No account, no key, no setup — it is keyless like every other Zen free model.
37
+
38
+ It thinks, and it has a 262,144-token window. That number was measured the hard way: the gateway happily accepts prompts four times that size and answers normally, but the model can no longer read them. A marker planted at the end of a 248,232-token prompt comes back; at 250,272 tokens it does not. So the advertised window is what the model can actually use, not merely what the service tolerates.
39
+
40
+ Two honest limits worth knowing before you pick it:
41
+
42
+ - **It is not available on the Responses API.** Requests sent that way are refused with a protocol error. It works on chat completions, which is what hosts use by default, but a host pinned specifically to Responses will not see it.
43
+ - **Thinking depth starts at "low".** The lowest setting is refused, so "minimal" is not offered; low, medium, high and extra-high all work.
44
+
45
+ The output budget is 131,072, the published figure for this model.
46
+
47
+ ### Patch Changes
48
+
49
+ - bf358da: Long sessions no longer stall on a tool call whose arguments arrived spliced
50
+
51
+ Some models open their next tool call by echoing the tail of the one they had just seen, and occasionally stop before the closing brace. The result is a single argument string carrying both faults at once: the previous call's leftover text in front, and the last brace missing. The whole call was then rejected, the turn failed, and the host retried — which resent the same broken payload, so a long session could get stuck repeating it.
52
+
53
+ Both faults are now repaired together, so the caller's own arguments arrive intact.
54
+
55
+ The repair stays deliberately narrow. It only fires when a leftover fragment is actually present in front of the object, which is the evidence that the model was mid-call rather than that the request itself was malformed, and only when what follows is a complete object merely missing its closing brace. Arguments cut off part-way through a value are still refused untouched, because completing those would silently shorten them — and these arguments carry shell commands.
56
+
57
+ Tool calls that were already well-formed are unaffected and pass through byte-identically.
58
+
3
59
  ## 1.35.0
4
60
 
5
61
  ### Minor Changes
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-freeflow
2
2
 
3
- > 36 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
3
+ > 37 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
4
4
 
5
5
  Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handles thinking, normalization, and provider behavior.
6
6
 
@@ -16,7 +16,7 @@ Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handl
16
16
 
17
17
  | Feature | What it does | Cost |
18
18
  | :--- | :--- | :--- |
19
- | **36 free models** | 10 from OpenCode Zen, 15 from KiloCode Gateway, 2 from Cline, 9 from OVHcloud AI Endpoints, context windows up to 1M. Full list below. | **$0** |
19
+ | **37 free models** | 11 from OpenCode Zen, 15 from KiloCode Gateway, 2 from Cline, 9 from OVHcloud AI Endpoints, context windows up to 1M. Full list below. | **$0** |
20
20
  | **Relay pool** | Route requests through your own Cloudflare Workers, Deno Deploy, and Vercel Edge relays. Traffic spreads across the pool, or stays on one sticky relay. A relay that rate-limits, times out, or drops the connection cools down while healthy ones take its traffic. | **$0** beyond your platforms' free tiers |
21
21
  | **Automatic fallback** | When every relay is cooling down, requests go direct to upstream instead of failing. | **$0** |
22
22
  | **Short model names** | Most models have a slash-free, colon-free alias, plus an optional `:effort` suffix for thinking depth. You type `freeflow/<name>`; the two Cline models have no alias and must be typed in full. | **$0** |
@@ -185,18 +185,19 @@ The same command set works identically in OMP and Pi:
185
185
 
186
186
  ---
187
187
 
188
- ### 36 models, one command
188
+ ### 37 models, one command
189
189
 
190
190
  ```bash
191
191
  /model → freeflow → pick
192
192
  ```
193
193
 
194
- #### OpenCode Zen (10 models), Responses and Chat API
194
+ #### OpenCode Zen (11 models), Responses and Chat API
195
195
 
196
196
  Good defaults for long coding sessions and agentic work.
197
197
 
198
198
  | Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
199
199
  | :--- | :--- | :--- | :--- | :--- | :--- |
200
+ | `cat-jam-free` | Cat Jam | **256K** (262.144) | **131K** (131.072) | `low … xhigh` | ❌ |
200
201
  | `muse-spark-1.2-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
201
202
  | `muse-spark-1.3-contributor-free` | Meta Superintelligence Labs | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
202
203
  | `mimo-v2.6-flash-free` | Xiaomi MiMo | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh`\* | ✅ |
@@ -358,7 +359,7 @@ pnpm smoke # verifies extensions/index.ts loads without crashing
358
359
  ```
359
360
  src/
360
361
  ├── index.ts # extension entry, lifecycle hooks
361
- ├── models.ts # 36-model catalog definitions
362
+ ├── models.ts # 37-model catalog definitions
362
363
  ├── catalog.ts # model catalog cache (24h disk)
363
364
  ├── proxy.ts # local proxy server (127.0.0.1:28180)
364
365
  ├── client.ts # session-side daemon attach/spawn and heartbeat
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.35.0",
4
+ "version": "1.36.1",
5
5
  "description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
6
6
  "main": "extensions/index.ts",
7
7
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * pi-freeflow — Modular, high-resiliency LLM extension for Pi & Oh My Pi (OMP)
3
3
  *
4
- * Provides access to 36 free models (10 OpenCode Zen + 15 KiloCode Gateway + 2 Cline + 9 OVH) with:
4
+ * Provides access to 37 free models (11 OpenCode Zen + 15 KiloCode Gateway + 2 Cline + 9 OVH) with:
5
5
  * - Single-port daemon reuse on 28180 across concurrent subagents
6
6
  * - Multi-cloud rolling egress relays (Vercel Edge, Cloudflare, Deno)
7
7
  * - 0ms instant startup with verified static catalog and background live health checks
package/src/models.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Static model definitions and upstream routing catalogs for pi-freeflow
3
3
  *
4
- * Defines the 36 verified free models (live-verified 2026-10-13):
5
- * - 10 OpenCode Zen models (2 Responses API + 8 Chat Completions)
4
+ * Defines the 37 verified free models (live-verified 2026-10-13):
5
+ * - 11 OpenCode Zen models (2 Responses API + 9 Chat Completions)
6
6
  * - 15 KiloCode Keyless Gateway models (OpenRouter format)
7
7
  * - 2 Cline direct-only models (per-user pool)
8
8
  * - 9 OVHcloud AI Endpoints models (keyless, direct-only)
@@ -15,6 +15,27 @@ import type { ModelDef, ThinkingLevelMap, Upstream } from "./types.ts";
15
15
  * Endpoint: https://opencode.ai/zen/v1
16
16
  */
17
17
  export const OPENCODE_MODELS: ModelDef[] = [
18
+ {
19
+ id: "cat-jam-free",
20
+ name: "Cat Jam [OpenCode]",
21
+ reasoning: true,
22
+ // Context is 262_144, NOT the 1M an acceptance probe suggests: the gateway
23
+ // answers 200 to oversized prompts and silently truncates. A needle planted
24
+ // at the end of a 248,232-token prompt is recalled; at 250,272 it is not.
25
+ // Cross-checked against the published spec, which agrees.
26
+ contextWindow: 262_144,
27
+ maxTokens: 131_072,
28
+ input: ["text"],
29
+ thinkingLevelMap: {
30
+ off: null,
31
+ minimal: null,
32
+ low: "low",
33
+ medium: "medium",
34
+ high: "high",
35
+ xhigh: "xhigh",
36
+ max: null,
37
+ },
38
+ },
18
39
  {
19
40
  id: "muse-spark-1.2-contributor-free",
20
41
  name: "Muse Spark 1.2 [OpenCode]",
@@ -557,8 +557,11 @@ export function enforceOpencodeFingerprint(
557
557
  const injected = missing.filter((n) => !ompLike || n === "edit");
558
558
  const injectedReal = ompLike ? missing.filter((n) => n !== "edit") : [];
559
559
 
560
+ // Zen alone asks for a rename: its free-tier gate looks for the sextet name
561
+ // `glob`, so a Pi caller that sent `find` needs it renamed into that slot.
562
+ // Every other lane accepts the caller's own names untouched.
560
563
  if (Array.isArray(body.tools)) {
561
- body.tools = translateToolsForPath(body.tools, pathname);
564
+ body.tools = translateToolsForPath(body.tools, pathname, { forFingerprint: true });
562
565
  }
563
566
 
564
567
  const before = Array.isArray(body.tools) ? body.tools.length : 0;
@@ -881,10 +884,14 @@ export function sseToResponsesJson(
881
884
  }
882
885
  } catch { }
883
886
 
887
+ // Nothing in the stream carried a response object: the upstream turn ended
888
+ // without one. Report that honestly — a `completed` answer with zero output
889
+ // reads to the host as "the model chose to say nothing", which is a
890
+ // different (and wrong) claim than "the stream was cut".
884
891
  return {
885
892
  id: "resp_fallback",
886
893
  object: "response",
887
- status: "completed",
894
+ status: "incomplete",
888
895
  output: [],
889
896
  };
890
897
  }
@@ -895,8 +902,11 @@ export function sseToResponsesJson(
895
902
  * never see tool_use: stray placeholder input folds to text, matching the
896
903
  * SSE aggregator fallback below. Ids ride verbatim; `input` is repaired
897
904
  * against the caller's declared schema when the emitted shape cannot be right.
905
+ *
906
+ * Exported so the streaming cloak applies these identical rules to a `message`
907
+ * frame that arrives mid-stream, instead of forwarding it verbatim.
898
908
  */
899
- function cloakMessagesContent(
909
+ export function cloakMessagesContent(
900
910
  msg: unknown,
901
911
  callerHadTools: boolean,
902
912
  caseRestore?: CaseRestoreMap,
@@ -1089,7 +1099,79 @@ export function convertSseToJson(
1089
1099
  return JSON.stringify(sseToMessagesJson(trimmed, callerHadTools, caseRestore, findGlob, injected, argSchemas));
1090
1100
  }
1091
1101
  return JSON.stringify(sseToChatCompletionJson(trimmed, callerHadTools, caseRestore, findGlob, injected, argSchemas));
1102
+ } catch (error) {
1103
+ // Every caller has already committed to `content-type: application/json`
1104
+ // by the time this runs, so handing back the raw SSE bytes would deliver a
1105
+ // parse error with no diagnosis. Say what happened, and keep the payload
1106
+ // reachable under `raw` so nothing is silently destroyed.
1107
+ return JSON.stringify({
1108
+ error: {
1109
+ message: "upstream stream could not be aggregated into a JSON answer",
1110
+ detail: String(error).slice(0, 300),
1111
+ raw: sseText.slice(0, 2000),
1112
+ },
1113
+ });
1114
+ }
1115
+ }
1116
+
1117
+ /**
1118
+ * Repair the tool calls inside an already-JSON answer body.
1119
+ *
1120
+ * A lane that passes the caller's `stream` through untouched (Kilo) answers a
1121
+ * non-streaming caller with a plain `chat.completion` / `response` object, not
1122
+ * SSE. The SSE aggregators above never see that body, so the malformed
1123
+ * arguments reached the host unrepaired on exactly the requests that asked for
1124
+ * no stream. Repairs each call in place and re-serializes only when something
1125
+ * actually changed, so a well-formed answer keeps its byte-identical body.
1126
+ * Returns the input string untouched when it is not JSON or has no tool calls.
1127
+ */
1128
+ export function repairJsonToolArguments(
1129
+ bodyText: string,
1130
+ pathname: string,
1131
+ argSchemas: ReadonlyMap<string, Record<string, unknown>> | undefined,
1132
+ ): string {
1133
+ if (argSchemas === undefined || argSchemas.size === 0 || !bodyText.trimStart().startsWith("{")) return bodyText;
1134
+ let parsed: unknown;
1135
+ try {
1136
+ parsed = JSON.parse(bodyText);
1092
1137
  } catch {
1093
- return sseText;
1138
+ return bodyText;
1139
+ }
1140
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return bodyText;
1141
+ const body = parsed as Record<string, unknown>;
1142
+ let changed = false;
1143
+ const repairCall = (name: string, args: string): string => {
1144
+ const repaired = repairToolArguments(name, args, argSchemas);
1145
+ if (repaired !== args) changed = true;
1146
+ return repaired;
1147
+ };
1148
+ if (pathname.endsWith("/responses")) {
1149
+ const output = body.output;
1150
+ if (!Array.isArray(output)) return bodyText;
1151
+ for (const item of output) {
1152
+ if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
1153
+ const rec = item as Record<string, unknown>;
1154
+ if (rec.type !== "function_call" || typeof rec.name !== "string" || typeof rec.arguments !== "string") continue;
1155
+ rec.arguments = repairCall(rec.name, rec.arguments);
1156
+ }
1157
+ } else {
1158
+ const choices = body.choices;
1159
+ if (!Array.isArray(choices)) return bodyText;
1160
+ for (const choice of choices) {
1161
+ if (choice === null || typeof choice !== "object" || Array.isArray(choice)) continue;
1162
+ const message = (choice as Record<string, unknown>).message;
1163
+ if (message === null || typeof message !== "object" || Array.isArray(message)) continue;
1164
+ const calls = (message as Record<string, unknown>).tool_calls;
1165
+ if (!Array.isArray(calls)) continue;
1166
+ for (const call of calls) {
1167
+ if (call === null || typeof call !== "object" || Array.isArray(call)) continue;
1168
+ const fn = (call as Record<string, unknown>).function;
1169
+ if (fn === null || typeof fn !== "object" || Array.isArray(fn)) continue;
1170
+ const fnRec = fn as Record<string, unknown>;
1171
+ if (typeof fnRec.name !== "string" || typeof fnRec.arguments !== "string") continue;
1172
+ fnRec.arguments = repairCall(fnRec.name, fnRec.arguments);
1173
+ }
1174
+ }
1094
1175
  }
1176
+ return changed ? JSON.stringify(body) : bodyText;
1095
1177
  }
package/src/proxy.ts CHANGED
@@ -35,11 +35,12 @@ import {
35
35
  } from "./config.ts";
36
36
 
37
37
  import {
38
- convertSseToJson,
39
- enforceOpencodeFingerprint,
40
- sseToChatCompletionJson,
41
- type CaseRestoreMap,
42
- type FindGlobRestore,
38
+ convertSseToJson,
39
+ enforceOpencodeFingerprint,
40
+ repairJsonToolArguments,
41
+ sseToChatCompletionJson,
42
+ type CaseRestoreMap,
43
+ type FindGlobRestore,
43
44
  } from "./opencode-fingerprint.ts";
44
45
  import { isDebugEnabled, log } from "./logger.ts";
45
46
  import { getModelUpstream, isClineModel, isOvhModel, KILO_MODEL_IDS, MODEL_MAP, ovhUpstreamModelId, resolveCanonicalModelId } from "./models.ts";
@@ -47,10 +48,12 @@ import { ClineAuthError, refreshClineToken, toApiKey } from "./cline-device-auth
47
48
  import { rollChat } from "./cline-accounts.ts";
48
49
  import { fetchWithSystemCA } from "./system-ca-fetch.ts";
49
50
  import {
50
- chatResponsesJsonFromChatCompletion,
51
- chatResponsesSseFromChatCompletion,
52
- chatBodyFromResponsesBody,
53
- translateToolsForPath,
51
+ buildFindGlobRestore,
52
+ chatBodyFromResponsesBody,
53
+ chatResponsesJsonFromChatCompletion,
54
+ chatResponsesSseFromChatCompletion,
55
+ restoreToolNameForCaller,
56
+ translateToolsForPath,
54
57
  } from "./tool-translation.ts";
55
58
  import { buildArgRepairSchemas } from "./tool-args.ts";
56
59
  // normalize removed — host pi-ai already normalizes thinking/reasoning before proxy
@@ -271,6 +274,10 @@ async function handleClineRequest(opts: {
271
274
  // Cline bypasses the fingerprint, but its arguments still malform the same
272
275
  // way: capture the caller's own schemas before translation for repair.
273
276
  const argSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
277
+ // Translation renames the caller's `find` to `glob` upstream and folds any
278
+ // name the upstream shape forbids; without this map the answer returns under
279
+ // a name Pi never registered.
280
+ const findGlob = buildFindGlobRestore(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
274
281
  chatBody.stream = true;
275
282
  let upstreamRes: Response;
276
283
  let limitHint: ClineLimitHint | undefined;
@@ -334,8 +341,8 @@ async function handleClineRequest(opts: {
334
341
  req,
335
342
  reqId,
336
343
  "direct",
337
- argSchemas.size > 0
338
- ? { callerHadTools: true, argSchemas, pathname: "/v1/chat/completions" }
344
+ findGlob.renamedFindToGlob || findGlob.renamed !== undefined || argSchemas.size > 0
345
+ ? { callerHadTools: true, argSchemas, findGlob, pathname: "/v1/chat/completions" }
339
346
  : undefined,
340
347
  );
341
348
  return;
@@ -352,7 +359,7 @@ async function handleClineRequest(opts: {
352
359
  return;
353
360
  }
354
361
  if (!responsesRequest) {
355
- const data = convertSseToJson(sseText, pathname, true, undefined, undefined, undefined, argSchemas);
362
+ const data = convertSseToJson(sseText, pathname, true, undefined, findGlob, undefined, argSchemas);
356
363
  if (!res.headersSent) {
357
364
  res.writeHead(upstreamRes.status, { "content-type": "application/json" });
358
365
  res.end(data);
@@ -361,7 +368,7 @@ async function handleClineRequest(opts: {
361
368
  }
362
369
  return;
363
370
  }
364
- const chat = sseToChatCompletionJson(sseText, true, undefined, undefined, undefined, argSchemas);
371
+ const chat = sseToChatCompletionJson(sseText, true, undefined, findGlob, undefined, argSchemas);
365
372
  const resp = chatResponsesJsonFromChatCompletion(chat, model);
366
373
  if (clientRequestedStream) {
367
374
  if (!res.headersSent) {
@@ -423,6 +430,10 @@ async function handleOvhRequest(opts: {
423
430
  // Bypassing the fingerprint does not exempt OVH from malformed tool
424
431
  // arguments: capture the caller's own schemas before translation for repair.
425
432
  const argSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
433
+ // Translation renames the caller's `find` to `glob` upstream and folds any
434
+ // name the upstream shape forbids; without this map the answer returns under
435
+ // a name Pi never registered.
436
+ const findGlob = buildFindGlobRestore(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
426
437
  chatBody.stream = true;
427
438
  // Header-wait timeout + client-disconnect abort, cleared once headers arrive
428
439
  // so a long stream is not killed at the timeout ceiling.
@@ -501,8 +512,8 @@ async function handleOvhRequest(opts: {
501
512
  req,
502
513
  reqId,
503
514
  "direct",
504
- argSchemas.size > 0
505
- ? { callerHadTools: true, argSchemas, pathname: "/v1/chat/completions" }
515
+ findGlob.renamedFindToGlob || findGlob.renamed !== undefined || argSchemas.size > 0
516
+ ? { callerHadTools: true, argSchemas, findGlob, pathname: "/v1/chat/completions" }
506
517
  : undefined,
507
518
  );
508
519
  return;
@@ -519,7 +530,7 @@ async function handleOvhRequest(opts: {
519
530
  return;
520
531
  }
521
532
  if (!responsesRequest) {
522
- const data = convertSseToJson(sseText, pathname, true, undefined, undefined, undefined, argSchemas);
533
+ const data = convertSseToJson(sseText, pathname, true, undefined, findGlob, undefined, argSchemas);
523
534
  if (!res.headersSent) {
524
535
  res.writeHead(upstreamRes.status, { "content-type": "application/json" });
525
536
  res.end(data);
@@ -528,7 +539,7 @@ async function handleOvhRequest(opts: {
528
539
  }
529
540
  return;
530
541
  }
531
- const chat = sseToChatCompletionJson(sseText, true, undefined, undefined, undefined, argSchemas);
542
+ const chat = sseToChatCompletionJson(sseText, true, undefined, findGlob, undefined, argSchemas);
532
543
  const resp = chatResponsesJsonFromChatCompletion(chat, requestedModel);
533
544
  if (clientRequestedStream) {
534
545
  if (!res.headersSent) {
@@ -1382,7 +1393,11 @@ export function startProxy(
1382
1393
  } else {
1383
1394
  let rawText = await response.text();
1384
1395
  if (response.ok && kiloArgSchemas.size > 0) {
1396
+ // Kilo passes `stream` through verbatim, so a non-streaming caller gets
1397
+ // a plain JSON body: the SSE aggregator is a no-op on it, and the JSON
1398
+ // repair is what actually applies here.
1385
1399
  rawText = convertSseToJson(rawText, target.pathname, true, undefined, undefined, undefined, kiloArgSchemas);
1400
+ rawText = repairJsonToolArguments(rawText, target.pathname, kiloArgSchemas);
1386
1401
  }
1387
1402
  const data = withRateLimitHint(response.status, rawText);
1388
1403
  const ct = response.headers.get("content-type") || "application/json";
@@ -15,7 +15,7 @@ import {
15
15
  WATCHDOG_SSE_MIN_SAMPLES,
16
16
  WATCHDOG_SSE_WINDOW,
17
17
  } from "./config.ts";
18
- import type { CaseRestoreMap, FindGlobRestore } from "./opencode-fingerprint.ts";
18
+ import { cloakMessagesContent, type CaseRestoreMap, type FindGlobRestore } from "./opencode-fingerprint.ts";
19
19
  import { restoreToolNameForCaller } from "./tool-translation.ts";
20
20
  import { repairToolArguments } from "./tool-args.ts";
21
21
 
@@ -211,6 +211,52 @@ export function createSseStreamCloakState(): SseStreamCloakState {
211
211
  };
212
212
  }
213
213
 
214
+ /**
215
+ * Emit every tool call still holding buffered arguments, then clear the
216
+ * buffers. The fragments were withheld so the packet could be judged whole
217
+ * (see {@link SseStreamCloakState}); the terminal event that normally releases
218
+ * them never arrived when upstream cut the stream without one, and without
219
+ * this the client would receive a tool call whose `arguments` were deleted and
220
+ * never re-emitted. Repaired exactly as the terminal path repairs.
221
+ *
222
+ * Shape follows the pathname: Chat re-emits one `tool_calls` delta, Anthropic
223
+ * one `input_json_delta` per open block. Responses carries whole arguments on
224
+ * its `.done` event and buffers nothing, so it has nothing to flush.
225
+ * Returns null when nothing is buffered.
226
+ */
227
+ export function flushBufferedToolArgs(
228
+ state: SseStreamCloakState,
229
+ cloak: StreamCloakOptions,
230
+ ): string | null {
231
+ if (state.messagesArgsByIndex.size > 0) {
232
+ const blocks: string[] = [];
233
+ for (const [index, args] of state.messagesArgsByIndex) {
234
+ const name = state.messagesNameByIndex.get(index) ?? "";
235
+ blocks.push(
236
+ `event: content_block_delta\ndata: ${JSON.stringify({
237
+ type: "content_block_delta",
238
+ index,
239
+ delta: { type: "input_json_delta", partial_json: repairToolArguments(name, args, cloak.argSchemas) },
240
+ })}\n\n`,
241
+ );
242
+ }
243
+ state.messagesArgsByIndex.clear();
244
+ state.messagesNameByIndex.clear();
245
+ return blocks.join("");
246
+ }
247
+ if (state.chatArgsByIndex.size === 0) return null;
248
+ const calls: unknown[] = [];
249
+ for (const [index, args] of state.chatArgsByIndex) {
250
+ const name = state.chatNameByIndex.get(index);
251
+ if (name === undefined) continue;
252
+ calls.push({ index, type: "function", function: { name, arguments: repairToolArguments(name, args, cloak.argSchemas) } });
253
+ }
254
+ state.chatArgsByIndex.clear();
255
+ state.chatNameByIndex.clear();
256
+ if (calls.length === 0) return null;
257
+ return `data: ${JSON.stringify({ object: "chat.completion.chunk", choices: [{ index: 0, delta: { tool_calls: calls } }] })}\n\n`;
258
+ }
259
+
214
260
  /**
215
261
  * True when the stream needs no rewrite and must flow byte-identical:
216
262
  * nothing injected, no casing to restore, no find->glob rename, and the
@@ -656,6 +702,27 @@ function rewriteMessagesBlock(
656
702
  const stopBlock = emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
657
703
  return `${emitSseBlock("content_block_delta", JSON.stringify(flushed), false)}\n\n${stopBlock}`;
658
704
  }
705
+ // A `message` frame carries a whole finished message mid-stream. It needs the
706
+ // same treatment the aggregate path gives it — injected tool_use dropped,
707
+ // caller names restored, inputs repaired — otherwise a placeholder call leaks
708
+ // downstream and a malformed one reaches the host unrepaired. Any block still
709
+ // buffering arguments is completed first so nothing is orphaned.
710
+ if (type === "message") {
711
+ const flushed = flushBufferedToolArgs(state, cloak);
712
+ cloakMessagesContent(parsed.message ?? parsed, cloak.callerHadTools, cloak.caseRestore, cloak.findGlob, cloak.injected, cloak.argSchemas);
713
+ if (flushed === null) return emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix);
714
+ return `${flushed}${emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix)}`;
715
+ }
716
+ // A tool_use block still holding buffered arguments is completed HERE, while
717
+ // its own event order is intact: `message_delta` / `message_stop` close the
718
+ // message, so the recovered `input_json_delta` has to precede them. (An
719
+ // end-of-stream flush would emit it after `message_stop`, where a strict
720
+ // Anthropic client has already closed the message.)
721
+ if (type === "message_delta" || type === "message_stop") {
722
+ const flushed = flushBufferedToolArgs(state, cloak);
723
+ if (flushed === null) return block;
724
+ return `${flushed}${emitSseBlock(event, JSON.stringify(parsed), hadEventPrefix)}`;
725
+ }
659
726
  return block;
660
727
  }
661
728
 
@@ -1099,6 +1166,15 @@ export function pipeUpstreamStream(
1099
1166
  }
1100
1167
  }
1101
1168
  }
1169
+ // Buffered tool arguments are released on the call's terminal event, which a
1170
+ // cut stream never sends. Flush them here: the client already saw the call
1171
+ // announced, so it must receive the arguments too rather than a name with no
1172
+ // payload. Runs before the terminal-marker decision and before the re-fire
1173
+ // offer, because a rescue regenerates the turn and would double the calls.
1174
+ if (cloakState && cloak) {
1175
+ const flushed = flushBufferedToolArgs(cloakState, cloak);
1176
+ if (flushed !== null) forwardText(flushed);
1177
+ }
1102
1178
  if (hasTerminalEvent) recordOnce(false);
1103
1179
  const elapsed = ((Date.now() - startAt) / 1000).toFixed(1);
1104
1180
  if (!hasTerminalEvent && totalChunks > 0) {
@@ -1140,6 +1216,14 @@ export function pipeUpstreamStream(
1140
1216
  // inject a terminal nor end res — the replacement stream owns the turn now.
1141
1217
  if (refirePending) return;
1142
1218
  try {
1219
+ // Same obligation as the clean-end flush: the client already saw the call
1220
+ // announced, so its buffered arguments must still arrive before the turn is
1221
+ // terminated — and before the re-fire offer, since a rescue regenerates the
1222
+ // turn and would double the calls.
1223
+ if (cloakState && cloak) {
1224
+ const flushed = flushBufferedToolArgs(cloakState, cloak);
1225
+ if (flushed !== null) forwardText(flushed);
1226
+ }
1143
1227
  if (!hasTerminalEvent && totalChunks > 0) {
1144
1228
  if (clientAborted || upstreamEnded) {
1145
1229
  // Client disconnect teardown or post-end cleanup — the relay
package/src/tool-args.ts CHANGED
@@ -9,9 +9,12 @@
9
9
  * `items: { item: [...] }`. Sibling free models invented whole vocabularies
10
10
  * (`phases` / `name` / `tasks`) the schema never declared.
11
11
  *
12
- * Two families are mechanically decidable from the CALLER's own declared
12
+ * Three families are mechanically decidable from the CALLER's own declared
13
13
  * schema, and this module repairs exactly those:
14
14
  *
15
+ * - Prefix-glue salvage: an argument string carrying the tail of the previous
16
+ * call in front of the caller's own object is recovered (see parseArguments).
17
+ *
15
18
  * - Envelope unwrap: a declared array that arrives wrapped in a single-key
16
19
  * object (`items: { item: [...] }`) is unwrapped.
17
20
  * - JSON-string decode: a declared object/array that arrives as a JSON string
@@ -38,6 +41,14 @@ import { canonicalizeTool } from "./tool-translation.ts";
38
41
  /** Bound on recursive repair/validation; real host schemas nest 3-4 deep. */
39
42
  const MAX_DEPTH = 8;
40
43
 
44
+ /**
45
+ * Text ending in a bare numeric literal (`"timeout":18`, `"n":1.`, `"n":1e`).
46
+ * A complete JSON value is never followed by a digit, so this can only be a
47
+ * literal the model was still writing when the stream cut — see
48
+ * {@link appendClosers}.
49
+ */
50
+ const CUT_NUMBER_TAIL = /[\d.eE+-]$/;
51
+
41
52
  /** Declared JSON Schema type, tolerating union types like ["array","null"]. */
42
53
  function schemaType(schema: Record<string, unknown>): string {
43
54
  const declared = schema.type;
@@ -49,10 +60,47 @@ function schemaType(schema: Record<string, unknown>): string {
49
60
  return "";
50
61
  }
51
62
 
52
- /** A schema property worth repairing: it holds an object or an array. */
53
- function structuralProperty(schema: Record<string, unknown>): boolean {
54
- const type = schemaType(schema);
55
- return type === "object" || type === "array";
63
+
64
+ /**
65
+ * Does a value already satisfy the declared schema? Type, then required keys,
66
+ * then each present property recursively. A present property the schema does
67
+ * not declare is ignored (never fails): hosts decide their own extras policy,
68
+ * and this only gates whether a promotion is safe.
69
+ *
70
+ * Deliberately reads five keywords — `type`, `enum`, `properties`, `items`,
71
+ * `required` — and nothing else. `minItems`, `minLength`, `pattern`,
72
+ * `additionalProperties` and `strict` are NOT enforced, on purpose: this
73
+ * function only decides whether to hand the repaired value to the host, and
74
+ * the host validates the same keywords itself. A payload that violates
75
+ * `minItems` is rejected by the host whether or not the repair accepted it, so
76
+ * enforcing it here could only ever DISABLE a repair that would have been
77
+ * accepted downstream — strictly worse, never better. The trade-off this does
78
+ * leave: repair never rescues a call whose only fault is a constraint like
79
+ * `minItems`, because the shape was never the problem.
80
+ */
81
+ /**
82
+ * Is this property allowed to be absent?
83
+ *
84
+ * Both hosts rewrite a tool's schema for strict mode by listing EVERY property
85
+ * key in `required` and wrapping each optional one in
86
+ * `anyOf: [<schema>, { type: "null" }]` (OMP: packages/ai CONSTRAINTS.md:34-49;
87
+ * Pi: core/constrained-sampling.ts:118-123). So "required" there does not mean
88
+ * "must be present" — it means "must be addressable". Reading it literally made
89
+ * this gate refuse every well-formed call on a strict tool, which silently
90
+ * disabled the whole repair for `bash` and the strict edit modes: the glued
91
+ * payload the host rejects is exactly the one this module exists to fix.
92
+ */
93
+ function isNullableProperty(prop: unknown): boolean {
94
+ if (prop === null || typeof prop !== "object" || Array.isArray(prop)) return false;
95
+ const rec = prop as Record<string, unknown>;
96
+ if (Array.isArray(rec.type)) return (rec.type as unknown[]).includes("null");
97
+ const anyOf = rec.anyOf;
98
+ if (Array.isArray(anyOf)) {
99
+ for (const branch of anyOf) {
100
+ if (branch !== null && typeof branch === "object" && !Array.isArray(branch) && (branch as Record<string, unknown>).type === "null") return true;
101
+ }
102
+ }
103
+ return false;
56
104
  }
57
105
 
58
106
  /**
@@ -60,38 +108,74 @@ function structuralProperty(schema: Record<string, unknown>): boolean {
60
108
  * then each present property recursively. A present property the schema does
61
109
  * not declare is ignored (never fails): hosts decide their own extras policy,
62
110
  * and this only gates whether a promotion is safe.
111
+ *
112
+ * Deliberately reads `type`, `enum`, `properties`, `items`, `required` — and
113
+ * nothing else. `minItems`, `minLength`, `pattern`, `additionalProperties` and
114
+ * `strict` are NOT enforced, on purpose: this function only decides whether to
115
+ * hand the repaired value to the host, and the host validates the same
116
+ * keywords itself. A payload that violates `minItems` is rejected by the host
117
+ * whether or not the repair accepted it, so enforcing it here could only ever
118
+ * DISABLE a repair the host would otherwise have accepted — strictly worse,
119
+ * never better. The trade-off this leaves: repair never rescues a call whose
120
+ * only fault is a constraint like `minItems`, because the shape was never the
121
+ * problem.
63
122
  */
64
123
  function matchesSchema(value: unknown, schema: Record<string, unknown>, depth: number): boolean {
65
- if (depth > MAX_DEPTH) return false;
66
- const type = schemaType(schema);
67
- if (type === "array") {
68
- if (!Array.isArray(value)) return false;
69
- const items = schema.items;
70
- if (items === null || typeof items !== "object" || Array.isArray(items)) return true;
71
- return value.every((element) => matchesSchema(element, items as Record<string, unknown>, depth + 1));
72
- }
73
- if (type === "object") {
74
- if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
75
- const rec = value as Record<string, unknown>;
76
- if (Array.isArray(schema.required)) {
77
- for (const key of schema.required) {
78
- if (typeof key === "string" && !(key in rec)) return false;
79
- }
80
- }
81
- const props = schema.properties;
82
- if (props === null || typeof props !== "object" || Array.isArray(props)) return true;
83
- for (const [key, entry] of Object.entries(rec)) {
84
- const propSchema = (props as Record<string, unknown>)[key];
85
- if (propSchema === null || typeof propSchema !== "object" || Array.isArray(propSchema)) continue;
86
- if (!matchesSchema(entry, propSchema as Record<string, unknown>, depth + 1)) return false;
124
+ if (depth > MAX_DEPTH) return false;
125
+ const type = schemaType(schema);
126
+ if (type === "array") {
127
+ if (!Array.isArray(value)) return false;
128
+ const items = schema.items;
129
+ if (items === null || typeof items !== "object" || Array.isArray(items)) return true;
130
+ return value.every((element) => matchesSchema(element, items as Record<string, unknown>, depth + 1));
131
+ }
132
+ if (type === "object") {
133
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
134
+ const rec = value as Record<string, unknown>;
135
+ const props = schema.properties;
136
+ const declared = props !== null && typeof props === "object" && !Array.isArray(props) ? (props as Record<string, unknown>) : null;
137
+ if (Array.isArray(schema.required)) {
138
+ for (const key of schema.required) {
139
+ if (typeof key !== "string" || key in rec) continue;
140
+ // Strict-mode hosts list every key here and mark the optional ones nullable,
141
+ // so a nullable key that is absent is correct, not a violation.
142
+ if (declared !== null && isNullableProperty(declared[key])) continue;
143
+ return false;
87
144
  }
88
- return true;
89
145
  }
90
- if (type === "string") return typeof value === "string";
91
- if (type === "number") return typeof value === "number" && Number.isFinite(value);
92
- if (type === "boolean") return typeof value === "boolean";
93
- if (Array.isArray(schema.enum)) return schema.enum.some((member) => member === value);
146
+ if (declared === null) return true;
147
+ for (const [key, entry] of Object.entries(rec)) {
148
+ const propSchema = declared[key];
149
+ if (propSchema === null || typeof propSchema !== "object" || Array.isArray(propSchema)) continue;
150
+ if (!matchesSchema(entry, propSchema as Record<string, unknown>, depth + 1)) return false;
151
+ }
94
152
  return true;
153
+ }
154
+ if (type === "string") return typeof value === "string";
155
+ if (type === "number") return typeof value === "number" && Number.isFinite(value);
156
+ if (type === "boolean") return typeof value === "boolean";
157
+ if (Array.isArray(schema.enum)) return schema.enum.some((member) => member === value);
158
+ return true;
159
+ }
160
+
161
+ /** The spellings free models use for "no value" when the slot is nullable. */
162
+ const NULL_SENTINELS = new Set(["null", "none", "undefined", "nil", "n/a"]);
163
+
164
+ function isNullSentinel(text: string): boolean {
165
+ return NULL_SENTINELS.has(text.trim().toLowerCase());
166
+ }
167
+
168
+ /** The non-null branch of a nullable declaration, or null when it is not one. */
169
+ function nonNullBranch(prop: Record<string, unknown>): Record<string, unknown> | null {
170
+ const anyOf = prop.anyOf;
171
+ if (!Array.isArray(anyOf)) return null;
172
+ for (const branch of anyOf) {
173
+ if (branch === null || typeof branch !== "object" || Array.isArray(branch)) continue;
174
+ const rec = branch as Record<string, unknown>;
175
+ if (rec.type === "null") continue;
176
+ return rec;
177
+ }
178
+ return null;
95
179
  }
96
180
 
97
181
  /**
@@ -164,6 +248,24 @@ function repairValue(
164
248
  out[key] = entry;
165
249
  continue;
166
250
  }
251
+ // A strict host makes optional keys `anyOf: [T, null]`, and free models
252
+ // routinely answer that slot with the SENTINEL AS TEXT — `"timeout":"null"`,
253
+ // `"pty":"None"` (measured across 4 of 37 catalog models). The host rejects
254
+ // the call because a string is not a number and not null.
255
+ //
256
+ // Restricted to non-string inner types on purpose: when T is `string`,
257
+ // `"null"` could be a genuine value and rewriting it would destroy data.
258
+ // With T = number/boolean/object/array the property can never legitimately
259
+ // hold a bare string, so collapsing a null sentinel loses nothing and makes
260
+ // the call one the host accepts.
261
+ if (typeof entry === "string" && isNullSentinel(entry)) {
262
+ const nonNull = nonNullBranch(propSchema as Record<string, unknown>);
263
+ if (nonNull !== null && schemaType(nonNull) !== "string") {
264
+ out[key] = null;
265
+ changed = true;
266
+ continue;
267
+ }
268
+ }
167
269
  const repaired = repairValue(entry, propSchema as Record<string, unknown>, depth + 1);
168
270
  if (repaired.changed) changed = true;
169
271
  out[key] = repaired.value;
@@ -318,28 +420,29 @@ function promoteStringArray(
318
420
 
319
421
  /**
320
422
  * Per-request map of caller tool name (lowercased) to the schema its
321
- * arguments are repaired against. Only tools whose parameters declare at least
322
- * one object or array property are kept: those are the only ones a repair can
323
- * act on, and an empty map means the request streams byte-identically.
423
+ * arguments are repaired against. Every tool that declares object parameters
424
+ * is kept, NOT only those with an object/array property: the prefix-glue
425
+ * salvage in {@link parseArguments} needs only the caller's own schema to
426
+ * confirm the recovered object, and it fires most often on scalar-only tools.
427
+ * `bash` is the live case — OMP's `bashSchemaBase` declares only `command`,
428
+ * `timeout`, `cwd`, `pty` (reference/oh-my-pi tools/bash.ts:327), so a
429
+ * structural-only filter left the most-used tool unrepairable and the host
430
+ * rejected the glued payload outright.
324
431
  *
325
432
  * Keyed by CALLER name, which is what arrives downstream after the casing and
326
433
  * find->glob restores. Injected fingerprint placeholders are absent by
327
434
  * construction, so they are never repaired.
328
435
  */
329
436
  export function buildArgRepairSchemas(tools: unknown[]): Map<string, Record<string, unknown>> {
330
- const schemas = new Map<string, Record<string, unknown>>();
331
- for (const tool of tools) {
332
- const canon = canonicalizeTool(tool);
333
- if (canon === null) continue;
334
- const props = canon.parameters.properties;
335
- if (props === null || typeof props !== "object" || Array.isArray(props)) continue;
336
- const hasStructural = Object.values(props as Record<string, unknown>).some(
337
- (prop) => prop !== null && typeof prop === "object" && !Array.isArray(prop) && structuralProperty(prop as Record<string, unknown>),
338
- );
339
- if (!hasStructural) continue;
340
- schemas.set(canon.name.trim().toLowerCase(), canon.parameters);
341
- }
342
- return schemas;
437
+ const schemas = new Map<string, Record<string, unknown>>();
438
+ for (const tool of tools) {
439
+ const canon = canonicalizeTool(tool);
440
+ if (canon === null) continue;
441
+ const props = canon.parameters.properties;
442
+ if (props === null || typeof props !== "object" || Array.isArray(props)) continue;
443
+ schemas.set(canon.name.trim().toLowerCase(), canon.parameters);
444
+ }
445
+ return schemas;
343
446
  }
344
447
 
345
448
 
@@ -357,8 +460,12 @@ export function buildArgRepairSchemas(tools: unknown[]): Map<string, Record<stri
357
460
  *
358
461
  * Both are one complete JSON object with junk in front of it. Only strings that
359
462
  * do not parse as-is are considered, and only a position that yields a complete
360
- * object is accepted, so a well-formed call never reaches this path and a
361
- * truncated one is still refused rather than completed.
463
+ * object is accepted, so a well-formed call never reaches this path.
464
+ *
465
+ * A second live shape (2026-10-13) carried BOTH faults at once — the previous
466
+ * call's tail in front AND the closing brace missing — so the scan also offers
467
+ * the candidate with its closers appended. That offer is made only at the first
468
+ * brace and only when a fragment precedes it; see appendClosers.
362
469
  *
363
470
  * Returns undefined when nothing salvageable is present.
364
471
  */
@@ -370,17 +477,105 @@ function parseArguments(argsJson: string): { value: Record<string, unknown>; sal
370
477
  } catch {
371
478
  // fall through to the salvage scan
372
479
  }
373
- for (let i = argsJson.indexOf("{"); i !== -1; i = argsJson.indexOf("{", i + 1)) {
374
- try {
375
- const value = JSON.parse(argsJson.slice(i));
376
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
377
- return { value: value as Record<string, unknown>, salvaged: true };
378
- }
379
- } catch {
380
- // not a start position; try the next brace
480
+ // Only a brace that OPENS a top-level object can start the caller's call: a
481
+ // brace nested inside an already-unterminated object belongs to that outer
482
+ // object, so promoting it would drop the declared key that wraps it
483
+ // (`{"opts":{"a":1` must not salvage to `{"a":1}`).
484
+ for (const start of topLevelObjectStarts(argsJson)) {
485
+ const candidate = argsJson.slice(start);
486
+ const i = start;
487
+ // Two shapes to try, in order: the candidate as written, then the candidate
488
+ // with its missing closers appended. The closer is only offered at the FIRST
489
+ // brace and only when something precedes it — that fragment is the evidence
490
+ // the model was mid-call and lost the tail. A bare `{"op":"init"` stays
491
+ // refused, and so does an inner `{"phase":…` inside an already-truncated
492
+ // object, which is why this is not offered at every brace.
493
+ const candidates = i === 0 ? [candidate] : [candidate, appendClosers(candidate)];
494
+ for (const attempt of candidates) {
495
+ if (attempt === undefined) continue;
496
+ try {
497
+ const value = JSON.parse(attempt);
498
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
499
+ return { value: value as Record<string, unknown>, salvaged: true };
381
500
  }
501
+ } catch {
502
+ // not a start position; try the next brace
503
+ }
504
+ }
505
+ }
506
+ return undefined;
507
+ }
508
+
509
+ /**
510
+ * Offsets of every `{` that could open the caller's own object.
511
+ *
512
+ * Depth counts braces only, clamped at zero, and deliberately ignores string
513
+ * state. The junk in front is itself a cut fragment: it usually ends
514
+ * mid-string (`…/freelane-proxy"`), so string tracking would classify the whole
515
+ * real call as "inside a string" and find no start at all — which is exactly
516
+ * the live failure this scan has to recover from.
517
+ *
518
+ * A nested brace is a candidate only when no key precedes it: `{"opts":{"a":1}`
519
+ * must NOT promote its inner `{"a":1}` (a `:` sits between, so the outer
520
+ * object is a real one and its declared key would be dropped), while the
521
+ * observed prefix junk `}{` must still reach the real call that follows it.
522
+ */
523
+ function topLevelObjectStarts(text: string): number[] {
524
+ const starts: number[] = [];
525
+ const openAt: number[] = [];
526
+ for (let i = 0; i < text.length; i++) {
527
+ if (text[i] === "{") {
528
+ if (openAt.length === 0 || !text.slice(openAt[openAt.length - 1] + 1, i).includes(":")) starts.push(i);
529
+ openAt.push(i);
530
+ continue;
531
+ }
532
+ if (text[i] === "}" && openAt.length > 0) openAt.pop();
533
+ }
534
+ return starts;
535
+ }
536
+
537
+ /**
538
+ * Append the closers an unterminated JSON object is missing, or undefined when
539
+ * that would mean guessing.
540
+ *
541
+ * Only ever called on a candidate that starts at a `{` preceded by a fragment
542
+ * of the previous call, and only when the candidate's own contents already
543
+ * end cleanly. Three refusals keep this honest:
544
+ *
545
+ * - Cut inside a string literal (an unterminated `"`) returns undefined. The
546
+ * value itself is incomplete, so any closing brace would silently shorten
547
+ * it — and these arguments carry shell commands.
548
+ * - Cut inside a number literal returns undefined. `{"timeout":18` may have
549
+ * been `180`, `18.5`, or `18`; completing it hands the host a confident wrong
550
+ * value with nothing to signal the guess. (`timeout` is a declared number on
551
+ * both hosts' `bash`.)
552
+ * - Nothing left open returns undefined, so a well-formed candidate never
553
+ * reaches this path.
554
+ *
555
+ * Appending a closer never adds or removes a value, so a caller whose payload
556
+ * parsed to a complete object here is getting back exactly what the model
557
+ * emitted, minus the previous call's echo.
558
+ */
559
+ function appendClosers(text: string): string | undefined {
560
+ const open: string[] = [];
561
+ let inString = false;
562
+ let escaped = false;
563
+ for (const ch of text) {
564
+ if (inString) {
565
+ if (escaped) escaped = false;
566
+ else if (ch === "\\") escaped = true;
567
+ else if (ch === '"') inString = false;
568
+ continue;
382
569
  }
383
- return undefined;
570
+ if (ch === '"') inString = true;
571
+ else if (ch === "{" || ch === "[") open.push(ch === "{" ? "}" : "]");
572
+ else if (ch === "}" || ch === "]") open.pop();
573
+ }
574
+ // Inside a string at the end: the value was cut mid-token, so refuse.
575
+ if (inString || open.length === 0) return undefined;
576
+ // A bare number at the end is a cut literal (`18` of `180`), so refuse.
577
+ if (CUT_NUMBER_TAIL.test(text)) return undefined;
578
+ return text + open.reverse().join("");
384
579
  }
385
580
  /**
386
581
  * Repair one tool call's arguments against its declared schema. `name` is the
@@ -53,7 +53,17 @@ export const PI_TOOL_NAMES = [
53
53
  "ls",
54
54
  ] as const;
55
55
 
56
- /** OMP host built-ins: BUILTIN_TOOL_NAMES in reference/oh-my-pi packages/coding-agent/src/tools/builtin-names.ts plus browser/computer */
56
+ /**
57
+ * OMP host built-ins: BUILTIN_TOOL_NAMES in
58
+ * reference/oh-my-pi/packages/coding-agent/src/tools/builtin-names.ts, plus
59
+ * browser/computer (real top-level tools in the same checkout) and `hub`.
60
+ *
61
+ * `find` here is OMP's semantic search (reference/oh-my-pi
62
+ * tools/jfind/index.ts:54), NOT Pi's file finder: they are different tools
63
+ * with different schemas, and both are declared together whenever the session
64
+ * enables it. That is why the translator renames `find` to `glob` only when the
65
+ * caller left the `glob` slot empty — see translateToolsForPath.
66
+ */
57
67
  export const OMP_TOOL_NAMES = [
58
68
  "read",
59
69
  "bash",
@@ -62,15 +72,20 @@ export const OMP_TOOL_NAMES = [
62
72
  "ast_edit",
63
73
  "ask",
64
74
  "debug",
75
+ "ida",
65
76
  "eval",
66
77
  "github",
67
78
  "glob",
68
79
  "grep",
80
+ "find",
69
81
  "lsp",
70
82
  "checkpoint",
71
83
  "rewind",
84
+ "context_notes",
85
+ "new_context",
72
86
  "security_scan",
73
87
  "task",
88
+ "wait",
74
89
  "hub",
75
90
  "todo",
76
91
  "web_search",
@@ -180,13 +195,20 @@ export const OMP_CALLER_MARKERS = ["ask", "task", "todo", "hub", "lsp"] as const
180
195
 
181
196
  /**
182
197
  * True when a lowercased caller-tool name set looks like OMP: any of
183
- * ask/task/todo/hub/lsp present, or glob present without find (Pi sends find,
184
- * OMP sends glob). Case-insensitive; pass already-lowercased names.
198
+ * ask/task/todo/hub/lsp present, or `glob` present at all.
199
+ *
200
+ * `glob` alone is the discriminator because Pi has no such tool (its file
201
+ * finder is `find`, reference/pi core/tools/index.ts:95) while OMP declares
202
+ * both. The old test was "glob without find", which assumed `find` was
203
+ * Pi-exclusive; OMP ships a semantic `find` too (reference/oh-my-pi
204
+ * tools/jfind/index.ts:54), so an OMP read-only session carrying glob+find was
205
+ * classified as Pi and its fingerprint slots came back as non-executable
206
+ * placeholders. Case-insensitive; pass already-lowercased names.
185
207
  */
186
208
  export function isOmpLikeCaller(lowerNames: Iterable<string>): boolean {
187
209
  const set = lowerNames instanceof Set ? lowerNames : new Set(lowerNames);
188
210
  for (const m of OMP_CALLER_MARKERS) if (set.has(m)) return true;
189
- return set.has("glob") && !set.has("find");
211
+ return set.has("glob");
190
212
  }
191
213
 
192
214
  export interface CanonicalTool {
@@ -385,8 +407,14 @@ export function sanitizeUpstreamToolName(name: string, taken: ReadonlySet<string
385
407
  }
386
408
  /** Inspect the original caller tools (pre-translate) for the rewrite cases. */
387
409
  export function buildFindGlobRestore(callerTools: unknown[]): FindGlobRestore {
388
- let sawFind = false;
389
- let sawGlob = false;
410
+ // Same rule as translateToolsForPath: the find->glob rename fires only when
411
+ // the caller left the `glob` slot empty. OMP declares both real tools, so
412
+ // there the rename must not happen and `find` must keep its own name.
413
+ const callerDeclaresGlob = callerTools.some((tool) => {
414
+ const canon = canonicalizeTool(tool);
415
+ return canon !== null && canon.name.trim().toLowerCase() === "glob";
416
+ });
417
+ const nameFor = (name: string): string => (callerDeclaresGlob ? name.trim() : upstreamToolNameFor(name));
390
418
  const renamed: Record<string, string> = {};
391
419
  // Same first-seen-wins order and case rules as translateToolsForPath, so
392
420
  // the recorded map keys match the names that actually reach upstream.
@@ -395,25 +423,28 @@ export function buildFindGlobRestore(callerTools: unknown[]): FindGlobRestore {
395
423
  const canon = canonicalizeTool(tool);
396
424
  if (!canon) continue;
397
425
  const callerName = canon.name;
398
- if (isFindToolName(callerName)) sawFind = true;
399
- else if (callerName.trim().toLowerCase() === "glob") sawGlob = true;
426
+ const upstreamName = nameFor(callerName);
400
427
  // Mirrors translateToolsForPath: dedup on the lowercased
401
428
  // pre-sanitization name (a collapsed duplicate never reaches upstream),
402
429
  // then sanitize with the caller's casing intact.
403
- const dedupKey = upstreamToolNameFor(callerName).toLowerCase();
430
+ const dedupKey = upstreamName.toLowerCase();
404
431
  if (seen.has(dedupKey)) continue;
405
432
  seen.add(dedupKey);
406
- const sanitized = sanitizeUpstreamToolName(upstreamToolNameFor(callerName), seen);
433
+ const sanitized = sanitizeUpstreamToolName(upstreamName, seen);
407
434
  if (sanitized.toLowerCase() !== dedupKey) seen.add(sanitized.toLowerCase());
408
435
  // Only character-folding rewrites belong in the sanitized map. The
409
- // find->glob rename is a collision case with its own flag: when the
410
- // caller also sent `glob`, upstream `glob` really is the caller's glob,
411
- // so recording it here would misroute a genuine call.
436
+ // find->glob rename is a single-name fallback, so a name that came in
437
+ // as `find` must not be recorded here: upstream `glob` may be the
438
+ // caller's own glob.
412
439
  if (sanitized !== callerName && !isFindToolName(callerName)) {
413
440
  renamed[sanitized.toLowerCase()] = callerName;
414
441
  }
415
442
  }
416
- const restored: FindGlobRestore = { renamedFindToGlob: sawFind && !sawGlob, renamed };
443
+ const sawFind = callerTools.some((tool) => {
444
+ const canon = canonicalizeTool(tool);
445
+ return canon !== null && isFindToolName(canon.name);
446
+ });
447
+ const restored: FindGlobRestore = { renamedFindToGlob: sawFind && !callerDeclaresGlob, renamed };
417
448
  return restored;
418
449
  }
419
450
  /**
@@ -489,22 +520,46 @@ function carryExtraFields(
489
520
  }
490
521
  }
491
522
  /**
492
- * Reshape a caller tool array for the target path. Tools already in the
493
- * target shape pass through verbatim (identical reference, every extra field
494
- * intact) except Pi `find`, which is renamed to `glob` even when the shape
495
- * already matches (new object, params/description verbatim). Function tools
496
- * in another shape are converted, carrying `description`/`parameters`/`strict`
497
- * verbatim plus every other top-level caller field except the
498
- * wire-shape-forbidden keys (`type`/`function` wrapper, `name`, `description`,
499
- * `parameters`/`input_schema`), which are remapped per target shape. Never
500
- * injects `additionalProperties`. Every other entry passes through verbatim.
501
- * Duplicates collapse first-seen-wins on the lowercased upstream name, so
502
- * `Bash` never duplicates `bash` and `find` collapses with `glob`.
503
- * Re-translating translated output adds zero tools. Never returns null entries.
523
+ * Reshape a caller tool array for the target path. Tools already in the target
524
+ * shape pass through verbatim (identical reference, every extra field intact).
525
+ * Function tools in another shape are converted, carrying
526
+ * `description`/`parameters`/`strict` verbatim plus every other top-level
527
+ * caller field except the wire-shape-forbidden keys (`type`/`function`
528
+ * wrapper, `name`, `description`, `parameters`/`input_schema`), which are
529
+ * remapped per target shape. Never injects `additionalProperties`. Every other
530
+ * entry passes through verbatim. Duplicates collapse first-seen-wins on the
531
+ * lowercased upstream name, so `Bash` never duplicates `bash`. Re-translating
532
+ * translated output adds zero tools. Never returns null entries.
504
533
  * `tool_choice` is never imposed here; use the retarget helpers to carry it.
534
+ *
535
+ * Names ride the caller's own unless `forFingerprint` is set — that flag exists
536
+ * for the OpenCode free tier alone, whose gate expects the sextet name `glob`
537
+ * and therefore needs Pi's `find` renamed into it. On every other lane the
538
+ * rename is pure loss: the upstream would have accepted `find`, and the host
539
+ * gets `find` back and dispatches it natively.
505
540
  */
506
- export function translateToolsForPath(tools: unknown[], pathname: string): Record<string, unknown>[] {
541
+ export function translateToolsForPath(tools: unknown[], pathname: string, opts?: { forFingerprint?: boolean }): Record<string, unknown>[] {
507
542
  const api = apiForPathname(pathname);
543
+ // Only the OpenCode free tier needs a tool renamed: its gate looks for a
544
+ // sextet whose `glob` slot Pi's `find` can fill. Every other upstream accepts
545
+ // the caller's own names, so on those lanes the name rides verbatim in BOTH
546
+ // directions and the host dispatches it natively — nothing to translate, and
547
+ // nothing to restore.
548
+ //
549
+ // Even on the fingerprint lane the rename is skipped when the caller declared
550
+ // `glob` itself: OMP declares both `glob` (file patterns) and `find`
551
+ // (semantic search, reference/oh-my-pi tools/jfind/index.ts:54), and renaming
552
+ // there would collapse two real tools into one under the other's schema.
553
+ let callerDeclaresGlob = false;
554
+ for (const tool of tools) {
555
+ const canon = canonicalizeTool(tool);
556
+ if (canon && canon.name.trim().toLowerCase() === "glob") {
557
+ callerDeclaresGlob = true;
558
+ break;
559
+ }
560
+ }
561
+ const renameFind = opts?.forFingerprint === true && !callerDeclaresGlob;
562
+ const upstreamNameFor = (name: string): string => (renameFind ? upstreamToolNameFor(name) : name.trim());
508
563
  const seen = new Set<string>();
509
564
  const out: Record<string, unknown>[] = [];
510
565
  for (const tool of tools) {
@@ -517,15 +572,16 @@ export function translateToolsForPath(tools: unknown[], pathname: string): Recor
517
572
  continue;
518
573
  }
519
574
  // Duplicate collapse stays first-seen-wins on the lowercased upstream name
520
- // (`Bash` never duplicates `bash`, `find` collapses with `glob`) and runs on
521
- // the pre-sanitization name: the emitted name keeps the caller's casing, and
522
- // the sanitizer only ever folds characters the upstream shape forbids.
523
- const dedupKey = upstreamToolNameFor(canon.name).toLowerCase();
575
+ // (`Bash` never duplicates `bash`, a Pi `find` still collapses with `glob`)
576
+ // and runs on the pre-sanitization name: the emitted name keeps the caller's
577
+ // casing, and the sanitizer only ever folds characters the upstream shape
578
+ // forbids.
579
+ const dedupKey = upstreamNameFor(canon.name).toLowerCase();
524
580
  if (seen.has(dedupKey)) continue;
525
581
  seen.add(dedupKey);
526
582
  // The find->glob rename and the name sanitization share one upstream name,
527
583
  // so a caller tool needing either is emitted under a single new name.
528
- const upstreamName = sanitizeUpstreamToolName(upstreamToolNameFor(canon.name), seen);
584
+ const upstreamName = sanitizeUpstreamToolName(upstreamNameFor(canon.name), seen);
529
585
  if (upstreamName.toLowerCase() !== dedupKey) seen.add(upstreamName.toLowerCase());
530
586
  const needsRename = upstreamName !== canon.name;
531
587
  if (!needsRename && alreadyTargetShape(rec, api)) {
@@ -535,60 +591,17 @@ export function translateToolsForPath(tools: unknown[], pathname: string): Recor
535
591
  const renamed: CanonicalTool = needsRename
536
592
  ? { ...canon, name: upstreamName }
537
593
  : canon;
538
- const converted =
539
- api === "responses"
540
- ? toResponsesTool(renamed)
541
- : api === "messages"
542
- ? toAnthropicTool(renamed)
543
- : toChatTool(renamed);
544
- carryExtraFields(rec, converted);
545
- out.push(converted);
594
+ const converted =
595
+ api === "responses"
596
+ ? toResponsesTool(renamed)
597
+ : api === "messages"
598
+ ? toAnthropicTool(renamed)
599
+ : toChatTool(renamed);
600
+ carryExtraFields(rec, converted);
601
+ out.push(converted);
546
602
  }
547
603
  return out;
548
604
  }
549
- /**
550
- * Inject the missing fingerprint tools into an already-translated tool array,
551
- * using the target path's shape. Idempotent and case-insensitive: `Bash`
552
- * satisfies `bash` and is never duplicated.
553
- * OMP-like callers (explicit `ompLike`, else auto-detected from the array via
554
- * {@link isOmpLikeCaller}) get REAL minimal executable definitions from
555
- * {@link OMP_FINGERPRINT_DEFS}; everyone else (Pi) keeps the empty COMPAT
556
- * placeholders. `edit` is always the COMPAT placeholder, even for OMP-like
557
- * callers — no static edit def executes on either host, so it must stay
558
- * cloaked downstream. Re-injecting injected output adds zero tools.
559
- */
560
- export function injectFingerprintTools(
561
- tools: Record<string, unknown>[],
562
- pathname: string,
563
- ompLike?: boolean,
564
- ): Record<string, unknown>[] {
565
- const present = new Set<string>();
566
- for (const tool of tools) {
567
- const canon = canonicalizeTool(tool);
568
- if (canon) present.add(canon.name.trim().toLowerCase());
569
- }
570
- const omp = ompLike ?? isOmpLikeCaller(present);
571
- const api = apiForPathname(pathname);
572
- for (const name of OPENCODE_FINGERPRINT_TOOLS) {
573
- if (present.has(name.toLowerCase())) continue;
574
- const def = omp && name !== "edit" ? OMP_FINGERPRINT_DEFS[name] : null;
575
- const canon: CanonicalTool = {
576
- name,
577
- description: def ? def.description : COMPAT_TOOL_DESCRIPTION,
578
- parameters: def ? { ...(def.parameters as Record<string, unknown>) } : { type: "object", properties: {} },
579
- };
580
- tools.push(
581
- api === "responses"
582
- ? toResponsesTool(canon)
583
- : api === "messages"
584
- ? toAnthropicTool(canon)
585
- : toChatTool(canon),
586
- );
587
- present.add(name.toLowerCase());
588
- }
589
- return tools;
590
- }
591
-
592
605
  /**
593
606
  * Responses <-> Chat body translation. Cline serves chat completions only, so
594
607
  * the responses body is reshaped to chat before the Cline branch sends it,
@@ -675,7 +688,10 @@ export function chatBodyFromResponsesBody(body: Record<string, unknown>): Record
675
688
  out.messages = messages;
676
689
  if (Array.isArray(body.tools)) out.tools = translateToolsForPath(body.tools, "/v1/chat/completions");
677
690
  if (body.tool_choice !== undefined) out.tool_choice = body.tool_choice;
678
- for (const key of ["temperature", "top_p", "max_tokens", "max_completion_tokens", "stop", "presence_penalty", "frequency_penalty", "seed", "user", "parallel_tool_calls", "response_format"] as const) {
691
+ // `max_output_tokens` is the Responses-native cap and is what a host actually
692
+ // sets on this path; without it here the cap silently vanished on the
693
+ // chat-only lanes and the model answered at its own default length.
694
+ for (const key of ["temperature", "top_p", "max_tokens", "max_completion_tokens", "max_output_tokens", "stop", "presence_penalty", "frequency_penalty", "seed", "user", "parallel_tool_calls", "response_format"] as const) {
679
695
  if (body[key] !== undefined) out[key] = body[key];
680
696
  }
681
697
  return out;