@oai404iao/pi-codex-runtime 0.2.0 → 0.3.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/README.md CHANGED
@@ -4,12 +4,17 @@ Alpha bootstrap candidate; initial npm publication is still pending.
4
4
  It has no Pi extension entry
5
5
  and does not automatically register tools or providers.
6
6
 
7
- Peer floor: Pi 0.85.1; tested against 0.85.1.
7
+ Peer floor: Pi 0.86.1; tested against 0.86.1.
8
8
 
9
9
  Owns shared Codex authentication/headers, wire identity, settings/catalog,
10
10
  Responses replay contracts and the session-scoped composition broker.
11
11
  Capability-specific HTTP clients, storage and transports live in other packages.
12
12
 
13
+ Responses helpers encode/decode generic grammar tools and replay calls/results
14
+ according to the current declaration, preserving legacy custom patch behavior.
15
+ An internal structural event adapter lets owners offer optional Code Mode
16
+ contributions without importing or depending on the private Code Mode package.
17
+
13
18
  Configuration paths remain
14
19
  `<agentDir>/extensions/pi-codex-minimal-tools/{config,models}.json`.
15
20
  This package ships the canonical schemas and default catalog, including the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oai404iao/pi-codex-runtime",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "private": false,
5
5
  "description": "Unpublished pi-codex-runtime workspace for the staged Codex split",
6
6
  "type": "module",
@@ -23,8 +23,8 @@
23
23
  "./subagent-inline": "./src/subagent-inline.ts"
24
24
  },
25
25
  "peerDependencies": {
26
- "@earendil-works/pi-ai": ">=0.85.1",
27
- "@earendil-works/pi-coding-agent": ">=0.85.1"
26
+ "@earendil-works/pi-ai": ">=0.86.1",
27
+ "@earendil-works/pi-coding-agent": ">=0.86.1"
28
28
  },
29
29
  "peerDependenciesMeta": {
30
30
  "@earendil-works/pi-ai": {
@@ -36,8 +36,8 @@
36
36
  },
37
37
  "dependencies": {},
38
38
  "devDependencies": {
39
- "@earendil-works/pi-ai": "0.85.1",
40
- "@earendil-works/pi-coding-agent": "0.85.1",
39
+ "@earendil-works/pi-ai": "0.86.1",
40
+ "@earendil-works/pi-coding-agent": "0.86.1",
41
41
  "@types/node": "^26.2.0",
42
42
  "tsx": "^4.20.6",
43
43
  "typescript": "^7.0.2"
@@ -64,5 +64,5 @@
64
64
  "engines": {
65
65
  "node": ">=22.19.0"
66
66
  },
67
- "gitHead": "61f1d82471fff63e3e9d5c0276b5e069c9fc8f1f"
67
+ "gitHead": "c791e1192d0547834c3e2f2bae7853fb214f9808"
68
68
  }
package/src/broker.ts CHANGED
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { createRequire } from "node:module";
3
3
  import type { PackageToolName } from "./capabilities.js";
4
4
  import type { ProviderPresentation } from "./extension/provider-presentation.js";
5
+ import type { OwnerState } from "./code-mode-owner.js";
5
6
 
6
7
  export const CODEX_BROKER_CHANNEL = "@oai404iao/pi-codex:broker";
7
8
  export const CODEX_RUNTIME_VERSION: string = createRequire(import.meta.url)("../package.json").version;
@@ -10,6 +11,7 @@ const CACHE = Symbol.for("@oai404iao/pi-codex/broker/v1");
10
11
  export interface InstalledTool {
11
12
  register(): void;
12
13
  registered: boolean;
14
+ codeModeOwner?: OwnerState;
13
15
  }
14
16
 
15
17
  export interface CodexBroker {
@@ -18,6 +20,7 @@ export interface CodexBroker {
18
20
  readonly closed: boolean;
19
21
  readonly tools: Map<PackageToolName, InstalledTool>;
20
22
  coreEnabled: boolean;
23
+ codeModeDefinitions?: Map<string, { parameters: unknown; description: string; providerId: string }>;
21
24
  claim(name: string): boolean;
22
25
  addPresentation(name: string, presentation: ProviderPresentation): void;
23
26
  presentation: ProviderPresentation;
@@ -0,0 +1,139 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { getCodexBroker } from "./broker.js";
3
+ import { codeModeOwner, type DirectBinding } from "./code-mode-owner.js";
4
+ import type { PackageToolName } from "./capabilities.js";
5
+ import { randomUUID } from "node:crypto";
6
+
7
+ /** Unique public schema reference proves which registration won Pi's registry.
8
+ * If a future Pi clones metadata, cooperation fails closed instead of claiming
9
+ * a name/source belonging to another extension. */
10
+ export function registerCodeModeOwnedTool(pi: ExtensionAPI, definition: Record<string, unknown>, providerId: string): void {
11
+ if (typeof definition.name !== "string" || typeof definition.description !== "string"
12
+ || !definition.parameters || typeof definition.parameters !== "object"
13
+ || !/^[a-z][a-z0-9_]{0,39}$/.test(providerId)) throw new Error("Invalid owned Code Mode tool definition");
14
+ const broker = getCodexBroker(pi);
15
+ const definitions = broker.codeModeDefinitions ??= new Map();
16
+ if (definitions.has(definition.name)) throw new Error(`Duplicate Code Mode tool definition: ${definition.name}`);
17
+ const owned = { ...definition, name: definition.name, description: definition.description, parameters: { ...definition.parameters } };
18
+ pi.registerTool(owned as never);
19
+ definitions.set(owned.name, { ...owned, providerId });
20
+ }
21
+
22
+ export interface NestedCodeModeContext {
23
+ readonly cellId: string;
24
+ readonly toolCallId: string;
25
+ readonly cwd: string;
26
+ readonly signal: AbortSignal;
27
+ readonly pi?: ExtensionContext;
28
+ }
29
+ export interface OwnedCodeModeTool {
30
+ name: string;
31
+ description: string;
32
+ parameters: unknown;
33
+ effect: "read" | "write";
34
+ parallel?: boolean;
35
+ requires?: readonly string[];
36
+ requiredPolicies?: readonly string[];
37
+ approval?: string;
38
+ availability?: { state: "available" | "unavailable" | "not-ready" | "failed"; reason?: string };
39
+ direct?: DirectBinding;
40
+ invoke(input: unknown, context: NestedCodeModeContext): Promise<{ value: unknown }>;
41
+ }
42
+ const unavailableInvoke = async (): Promise<{ value: unknown }> => { throw new Error("Code Mode tool requirements were not negotiated"); };
43
+
44
+ /** Structural client for the optional v2/v1 bus contract. Neither side installs
45
+ * the other package; all authority still comes from Code Mode's exact grants. */
46
+ export function registerCodeModeContribution(pi: ExtensionAPI, id: string,
47
+ tools: (context: ExtensionContext) => readonly OwnedCodeModeTool[]): void {
48
+ let context: ExtensionContext | undefined;
49
+ let disposed = false;
50
+ let resolved: readonly OwnedCodeModeTool[] | undefined;
51
+ let registration: Readonly<{ owner: string; instanceId: string; revision: number }> = Object.freeze({ owner: id, instanceId: randomUUID(), revision: 1 });
52
+ let accepted = new WeakSet<object>();
53
+ const generations = new Map<string, number>();
54
+ const changed = (phase: "withdrawn" | "ready" | "disposed") => {
55
+ const change = Object.freeze({ protocol: 2, registration, kind: "execution", phase });
56
+ pi.events.emit("@oai404iao/pi-code-mode:changed/v2", change);
57
+ pi.events.emit("@oai404iao/pi-code-mode:changed/v1", { version: 1, change });
58
+ };
59
+ const snapshot = (features: readonly unknown[] = [], legacy = false) => {
60
+ const broker = getCodexBroker(pi);
61
+ const offered = context ? (resolved ??= tools(context).map((tool) => ({ ...tool }))) : [];
62
+ if (offered.length > 64) throw new Error("Code Mode declaration budget exceeded");
63
+ const declarations = offered.map((tool) => {
64
+ if (tool.requires !== undefined && (!Array.isArray(tool.requires) || tool.requires.length > 32))
65
+ throw new Error("Invalid Code Mode feature requirements");
66
+ const requires = [...new Set([...(tool.requires ?? []), ...(tool.approval ? ["approval/1"] : []),
67
+ ...(tool.requiredPolicies?.length ? ["required-policies/1"] : [])])];
68
+ if (requires.length > 32 || requires.some((feature) => typeof feature !== "string" || feature.length > 128
69
+ || !/^[a-z][a-z0-9-]*\/[1-9][0-9]*$/.test(feature)))
70
+ throw new Error("Invalid Code Mode feature requirements");
71
+ return { ...tool, requires };
72
+ });
73
+ return { id, tools: declarations.filter((tool) => !legacy || (!tool.requires.length && !tool.requiredPolicies?.length
74
+ && tool.approval === undefined && (!tool.availability || tool.availability.state === "available"))).map((tool) => {
75
+ if (tool.requires.some((feature) => !features.includes(feature)) || (tool.availability && tool.availability.state !== "available"))
76
+ return { ...tool, direct: undefined, invoke: unavailableInvoke };
77
+ const owned = broker.tools.get(tool.name as PackageToolName);
78
+ const definition = broker.codeModeDefinitions?.get(tool.name);
79
+ return { ...tool, direct: owned && definition?.providerId === id
80
+ ? codeModeOwner(pi, tool.name, owned, definition)?.binding : undefined };
81
+ }) };
82
+ };
83
+ const offV2 = pi.events.on("@oai404iao/pi-code-mode:discover/v2", (value) => {
84
+ const request = value as { hello?: { protocol?: number; instanceId?: string; generation?: number; features?: unknown[] };
85
+ offer?: (offer: unknown) => { status: string } } | undefined;
86
+ const hello = request?.hello;
87
+ if (disposed || !hello || hello.protocol !== 2 || !hello.instanceId || hello.instanceId.length > 128
88
+ || !Number.isSafeInteger(hello.generation) || hello.generation! < 1 || !Array.isArray(hello.features)
89
+ || hello.features.length > 32 || typeof request?.offer !== "function") return;
90
+ const previous = generations.get(hello.instanceId);
91
+ if ((previous !== undefined && hello.generation! < previous) || (previous === undefined && generations.size >= 64)) return;
92
+ generations.set(hello.instanceId, hello.generation!);
93
+ try {
94
+ const provider = snapshot(hello.features);
95
+ const availability = { state: context ? "available" : "not-ready" };
96
+ if (request.offer({ kind: "provider", registration, requires: [], availability, provider }).status === "compatible") accepted.add(hello);
97
+ } catch {
98
+ request.offer({ kind: "provider", registration, requires: [], availability: { state: "failed", reason: "owner-not-ready" }, provider: { id, tools: [] } });
99
+ }
100
+ });
101
+ const off = pi.events.on("@oai404iao/pi-code-mode:discover/v1", (value) => {
102
+ const discovery = value as { version?: number; provider?: (provider: unknown) => void;
103
+ consumer?: object; receipts?: { registration: object; consumer: object }[] } | undefined;
104
+ if (!disposed && context && discovery?.version === 1 && typeof discovery.provider === "function") {
105
+ if (discovery.consumer) {
106
+ const hello = discovery.consumer as { protocol?: number; instanceId?: string; generation?: number };
107
+ if (hello.protocol !== 2 || typeof hello.instanceId !== "string" || !hello.instanceId || hello.instanceId.length > 128
108
+ || !Number.isSafeInteger(hello.generation) || hello.generation! < 1) return;
109
+ const previous = generations.get(hello.instanceId);
110
+ if ((previous !== undefined && hello.generation! < previous) || (previous === undefined && generations.size >= 64)) return;
111
+ generations.set(hello.instanceId, hello.generation!);
112
+ }
113
+ if (discovery.consumer && accepted.has(discovery.consumer) && Array.isArray(discovery.receipts) && discovery.receipts.length <= 80
114
+ && discovery.receipts.some((receipt) => receipt.consumer === discovery.consumer && receipt.registration === registration)) return;
115
+ let provider: ReturnType<typeof snapshot>;
116
+ // Validate the whole declaration before filtering legacy tools:
117
+ // a failed v2 provider must not reappear as a partial v1 mirror.
118
+ try { provider = snapshot([], true); } catch { return; }
119
+ if (provider.tools.length) discovery.provider(provider);
120
+ }
121
+ });
122
+ const update = (_event: unknown, ctx: ExtensionContext) => {
123
+ if (disposed) return;
124
+ context = undefined;
125
+ resolved = undefined;
126
+ changed("withdrawn");
127
+ context = ctx;
128
+ registration = Object.freeze({ ...registration, revision: registration.revision + 1 });
129
+ accepted = new WeakSet();
130
+ changed("ready");
131
+ };
132
+ pi.on("session_start", update);
133
+ pi.on("model_select", update);
134
+ pi.on("session_tree", update);
135
+ pi.on("session_shutdown", () => {
136
+ if (disposed) return;
137
+ disposed = true; context = undefined; off(); offV2(); changed("disposed");
138
+ });
139
+ }
@@ -0,0 +1,96 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ export interface DirectBinding {
4
+ readonly version: 1; readonly name: string;
5
+ acquire(): { reconcile(): boolean; release(): void } | undefined;
6
+ }
7
+ interface Control {
8
+ binding: DirectBinding; readonly activeIntent: boolean | undefined;
9
+ projectActive(active: boolean): boolean | undefined;
10
+ reconcile(): boolean; dispose(): void;
11
+ }
12
+ interface Factory {
13
+ version: 1; create(pi: ExtensionAPI, options: { name: string; sourcePath: string }): Control;
14
+ }
15
+ export interface OwnerState {
16
+ identity: string; replaced?: boolean; factory?: Factory; control?: Control;
17
+ transitioning?: boolean; pendingDispose?: () => void; closed?: boolean;
18
+ }
19
+ export const OWNER_CHANGED = "@oai404iao/pi-code-mode:direct-owner-changed/v1";
20
+
21
+ function retire(state: OwnerState): void {
22
+ if (state.control && !state.pendingDispose) {
23
+ const old = state.control;
24
+ state.pendingDispose = () => old.dispose();
25
+ }
26
+ state.pendingDispose?.();
27
+ state.pendingDispose = undefined;
28
+ state.control = undefined;
29
+ state.factory = undefined;
30
+ }
31
+ export function disposeCodeModeOwner(tool: { codeModeOwner?: OwnerState }, close = true): void {
32
+ const state = tool.codeModeOwner;
33
+ if (!state) return;
34
+ if (close) state.closed = true;
35
+ if (state.transitioning) return;
36
+ state.transitioning = true;
37
+ try { retire(state); } finally { state.transitioning = false; }
38
+ }
39
+
40
+ /** Optional v1 bus client. No private-package dependency or global registry. */
41
+ export function codeModeOwner(pi: ExtensionAPI, name: string, tool: { registered: boolean; codeModeOwner?: OwnerState },
42
+ expected?: { parameters: unknown; description: string }): Control | undefined {
43
+ if (!["apply_patch", "web_search"].includes(name) || tool.codeModeOwner?.transitioning || tool.codeModeOwner?.closed) return;
44
+ if (!tool.registered) { disposeCodeModeOwner(tool, false); return; }
45
+ const info = pi.getAllTools?.().find((item) => item.name === name);
46
+ if (!info?.sourceInfo || ["builtin", "sdk"].includes(info.sourceInfo.source)
47
+ || !expected || info.parameters !== expected.parameters || info.description !== expected.description) {
48
+ (tool.codeModeOwner ??= { identity: "" }).replaced = true;
49
+ disposeCodeModeOwner(tool, false);
50
+ return;
51
+ }
52
+ const identity = JSON.stringify([info.sourceInfo, info.description, info.parameters, info.promptGuidelines]);
53
+ const stillOwns = () => {
54
+ const current = pi.getAllTools().find((item) => item.name === name);
55
+ return Boolean(current && current.parameters === expected.parameters
56
+ && JSON.stringify([current.sourceInfo, current.description, current.parameters, current.promptGuidelines]) === identity);
57
+ };
58
+ const state = tool.codeModeOwner ??= { identity };
59
+ // Never rebind a replaced registration, even if the optional factory reloads.
60
+ if (state.identity !== identity) state.replaced = true;
61
+ if (state.replaced) { disposeCodeModeOwner(tool, false); return; }
62
+ const factories: Factory[] = [];
63
+ pi.events.emit("@oai404iao/pi-code-mode:direct-owner/v1", { version: 1, accept(value: Factory) {
64
+ if (factories.length < 2) factories.push(value);
65
+ } });
66
+ const factory = factories.length === 1 && factories[0]?.version === 1
67
+ && typeof factories[0].create === "function" ? factories[0] : undefined;
68
+ if (state.closed) return;
69
+ if (!stillOwns()) {
70
+ state.replaced = true;
71
+ disposeCodeModeOwner(tool, false);
72
+ return;
73
+ }
74
+ if (state.pendingDispose || factory !== state.factory || (factory && !state.control)) {
75
+ state.transitioning = true;
76
+ try {
77
+ retire(state);
78
+ if (!stillOwns()) state.replaced = true;
79
+ if (state.closed || state.replaced) return;
80
+ if (factory) {
81
+ const control = factory.create(pi, { name, sourcePath: info.sourceInfo.path });
82
+ if (!control || control.binding?.version !== 1 || control.binding.name !== name
83
+ || typeof control.binding.acquire !== "function" || typeof control.projectActive !== "function"
84
+ || typeof control.reconcile !== "function" || typeof control.dispose !== "function") {
85
+ if (typeof control?.dispose === "function") state.pendingDispose = () => control.dispose();
86
+ throw new Error("Invalid Code Mode owner control");
87
+ }
88
+ state.control = control;
89
+ state.factory = factory;
90
+ if (!stillOwns()) state.replaced = true;
91
+ if (state.closed || state.replaced) { retire(state); return; }
92
+ }
93
+ } finally { state.transitioning = false; }
94
+ }
95
+ return state.control;
96
+ }
@@ -0,0 +1,52 @@
1
+ import type { Api, AssistantMessage, AssistantMessageEventStream, Model, Tool } from "@earendil-works/pi-ai";
2
+ import { grammarInputDelta, resolveGrammarSampling } from "./sampling.js";
3
+ import type { CustomToolCallState, OpenAIResponsesStreamOptions } from "./types.js";
4
+ import { localToolName } from "./text.js";
5
+
6
+ export function supportsGrammar(model: Model<Api>): boolean {
7
+ return (model.compat as { supportsOpenAIGrammarTools?: boolean } | undefined)?.supportsOpenAIGrammarTools === true;
8
+ }
9
+
10
+ /** Capture the actual request's custom declarations, including Lite namespaces.
11
+ * Legacy custom tools without sampling metadata keep their established {input}. */
12
+ export function responseGrammarProperties(body: { tools?: unknown; input?: unknown }, tools?: Tool[], includeLegacy = false): ReadonlyMap<string, string> {
13
+ const properties = new Map<string, string>();
14
+ const visit = (items: unknown, namespace?: string): void => {
15
+ if (!Array.isArray(items)) return;
16
+ for (const item of items) {
17
+ if (!item || typeof item !== "object") continue;
18
+ if (item.type === "namespace" && typeof item.name === "string") { visit(item.tools, item.name); continue; }
19
+ if (item.type !== "custom" || typeof item.name !== "string") continue;
20
+ const name = localToolName(namespace, item.name);
21
+ const tool = tools?.find((candidate) => candidate.name === name);
22
+ const grammar = tool && resolveGrammarSampling(tool, true);
23
+ if (grammar) properties.set(name, grammar.inputProperty);
24
+ else if (includeLegacy) properties.set(name, "input");
25
+ }
26
+ };
27
+ visit(body.tools);
28
+ if (Array.isArray(body.input)) for (const item of body.input) {
29
+ if (item?.type === "additional_tools") visit(item.tools);
30
+ }
31
+ return properties;
32
+ }
33
+
34
+ export function customArguments(name: string, input: string, options?: OpenAIResponsesStreamOptions): Record<string, string> {
35
+ return { [options?.grammarToolInputProperties?.get(name) ?? "input"]: input };
36
+ }
37
+
38
+ export function updateCustomInput(state: CustomToolCallState, input: string, close: boolean,
39
+ output: AssistantMessage, stream: AssistantMessageEventStream, options?: OpenAIResponsesStreamOptions): void {
40
+ const property = options?.grammarToolInputProperties?.get(state.block.name);
41
+ const previous = state.input;
42
+ state.input = input;
43
+ state.block.partialInput = input;
44
+ state.block.arguments = customArguments(state.block.name, input, options);
45
+ if (property !== undefined) {
46
+ state.inputJson ??= { input: "", started: false, closed: false };
47
+ const delta = grammarInputDelta(state.inputJson, property, input, close);
48
+ if (delta) stream.push({ type: "toolcall_delta", contentIndex: state.blockIndex, delta, partial: output });
49
+ } else if (!close && input.startsWith(previous) && input.length > previous.length) {
50
+ stream.push({ type: "toolcall_delta", contentIndex: state.blockIndex, delta: input.slice(previous.length), partial: output });
51
+ }
52
+ }
@@ -6,6 +6,7 @@ import { decodeWebSearchActivityTextSignature, isWebSearchActivityTextSignature,
6
6
  import { sanitizeSurrogates, shortHash } from "./text.js";
7
7
  import { wireToolIdentity } from "./tool-identity.js";
8
8
  import { type ConvertResponsesMessagesOptions, type InternalAssistantContent, type Message } from "./types.js";
9
+ import { grammarToolInput } from "./sampling.js";
9
10
 
10
11
  export function convertResponsesMessages<TApi extends Api>(
11
12
  model: Model<TApi>,
@@ -14,6 +15,7 @@ export function convertResponsesMessages<TApi extends Api>(
14
15
  options?: ConvertResponsesMessagesOptions,
15
16
  ): ResponseInput {
16
17
  const messages: ResponseInput = [];
18
+ const customCalls = new Map<string, boolean>();
17
19
  const normalizeIdPart = (part: string) => {
18
20
  const sanitized = part.replace(/[^a-zA-Z0-9_-]/g, "_");
19
21
  const normalized = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized;
@@ -92,9 +94,14 @@ export function convertResponsesMessages<TApi extends Api>(
92
94
  assistantBlockIndex++;
93
95
  } else if (block.type === "toolCall") {
94
96
  const [callId, itemIdRaw] = block.id.split("|");
95
- const custom = itemIdRaw?.startsWith("ctc_") === true;
97
+ const property = options?.grammarToolInputProperties?.get(block.name);
98
+ const declared = options?.grammarToolInputProperties !== undefined && context.tools?.some((tool) => tool.name === block.name);
99
+ const custom = property !== undefined || (!declared && itemIdRaw?.startsWith("ctc_") === true && typeof block.arguments.input === "string");
100
+ customCalls.set(callId, custom);
96
101
  let itemId: string | undefined = itemIdRaw;
97
102
  if (isDifferentModel && (itemId?.startsWith("fc_") || itemId?.startsWith("ctc_"))) itemId = undefined;
103
+ if (!custom && itemId?.startsWith("ctc_")) itemId = undefined;
104
+ if (custom && itemId?.startsWith("fc_")) itemId = undefined;
98
105
  const wireIdentity = wireToolIdentity(block.name, block.thoughtSignature);
99
106
  if (custom) {
100
107
  output.push({
@@ -103,7 +110,7 @@ export function convertResponsesMessages<TApi extends Api>(
103
110
  call_id: callId,
104
111
  name: wireIdentity.name,
105
112
  ...(wireIdentity.namespace ? { namespace: wireIdentity.namespace } : {}),
106
- input: typeof block.arguments.input === "string" ? block.arguments.input : "",
113
+ input: sanitizeSurrogates(grammarToolInput(block.name, block.arguments, property ?? "input")),
107
114
  } as ResponseInput[number]);
108
115
  } else {
109
116
  output.push({
@@ -157,7 +164,7 @@ export function convertResponsesMessages<TApi extends Api>(
157
164
  ]
158
165
  : sanitizeSurrogates(hasText ? textResult : "(see attached image)");
159
166
  messages.push({
160
- type: itemId?.startsWith("ctc_") ? "custom_tool_call_output" : "function_call_output",
167
+ type: (customCalls.get(callId) ?? (options?.grammarToolInputProperties?.has(msg.toolName) || itemId?.startsWith("ctc_"))) ? "custom_tool_call_output" : "function_call_output",
161
168
  call_id: callId,
162
169
  output,
163
170
  } as ResponseInput[number]);
@@ -0,0 +1,41 @@
1
+ import type { Tool } from "@earendil-works/pi-ai";
2
+
3
+ export interface GrammarInputBuffer { input: string; started: boolean; closed: boolean }
4
+ export interface GrammarSampling { format: "lark" | "regex"; definition: string; inputProperty: string }
5
+
6
+ // Pi's extension loader aliases SDK roots to files, so api/* subpath imports
7
+ // do not resolve in production extensions. Implement the small wire contract
8
+ // locally rather than resolving private SDK dist paths.
9
+ export function resolveGrammarSampling(tool: Tool, supported: boolean): GrammarSampling | undefined {
10
+ const sampling = tool.constrainedSampling;
11
+ if (!supported || !sampling || sampling.type !== "grammar") return undefined;
12
+ const schema = tool.parameters as { type?: unknown; required?: unknown; properties?: Record<string, { type?: unknown }> };
13
+ const required = schema.required;
14
+ if (schema.type !== "object" || !Array.isArray(required) || required.length !== 1
15
+ || typeof required[0] !== "string" || schema.properties?.[required[0]]?.type !== "string") {
16
+ throw new Error(`Tool ${tool.name} grammar requires exactly one required string property`);
17
+ }
18
+ for (const [variant, format] of [["openai_lark", "lark"], ["openai_regex", "regex"]] as const) {
19
+ const definition = sampling.variants[variant];
20
+ if (typeof definition === "string" && definition.trim()) return { format, definition, inputProperty: required[0] };
21
+ }
22
+ throw new Error(`Tool ${tool.name} has no supported grammar variant`);
23
+ }
24
+ export function grammarToolInput(name: string, args: Record<string, unknown>, property: string): string {
25
+ const value = args[property];
26
+ if (typeof value !== "string") throw new Error(`Grammar tool ${name} requires string argument ${property}`);
27
+ return value;
28
+ }
29
+ export function grammarInputDelta(buffer: GrammarInputBuffer, property: string, next: string, close: boolean): string | undefined {
30
+ if (buffer.closed) {
31
+ if (next !== buffer.input) throw new Error("Grammar input changed after it was closed");
32
+ return undefined;
33
+ }
34
+ if (!next.startsWith(buffer.input)) throw new Error("Grammar input is not monotonic");
35
+ const suffix = JSON.stringify(next.slice(buffer.input.length)).slice(1, -1);
36
+ const delta = (buffer.started ? "" : `{${JSON.stringify(property)}:"`) + suffix + (close ? '"}' : "");
37
+ buffer.input = next;
38
+ buffer.started = true;
39
+ buffer.closed = close;
40
+ return delta || undefined;
41
+ }
@@ -5,7 +5,8 @@ import { createResponsesStreamState } from "./stream-state.js";
5
5
  import { createResponseTextRenderer } from "./text-renderer.js";
6
6
  import { localToolName, parseStreamingJson } from "./text.js";
7
7
  import { encodeToolNamespaceSignature } from "./tool-identity.js";
8
- import type { TextBlock, ThinkingBlock, ToolCallBlock } from "./types.js";
8
+ import type { CustomToolCallState, TextBlock, ThinkingBlock, ToolCallBlock } from "./types.js";
9
+ import { customArguments, updateCustomInput } from "./grammar.js";
9
10
  import { type OpenAIResponsesStreamOptions } from "./types.js";
10
11
  import { finalizeResponseUsage } from "./usage.js";
11
12
 
@@ -97,12 +98,12 @@ export async function processResponsesStream<TApi extends Api>(
97
98
  type: "toolCall",
98
99
  id: `${customItem.call_id}|${itemId}`,
99
100
  name: localToolName(customItem.namespace, customItem.name),
100
- arguments: { input },
101
+ arguments: customArguments(localToolName(customItem.namespace, customItem.name), input, options),
101
102
  ...(thoughtSignature ? { thoughtSignature } : {}),
102
103
  partialInput: input,
103
104
  };
104
105
  pushResponseBlock(currentBlock, event.output_index);
105
- outputStates.set(event.output_index, {
106
+ const state: CustomToolCallState = {
106
107
  kind: "custom_tool_call",
107
108
  blockIndex: blockIndex(),
108
109
  block: currentBlock,
@@ -110,8 +111,10 @@ export async function processResponsesStream<TApi extends Api>(
110
111
  sourceItemId: customItem.id,
111
112
  callId: customItem.call_id,
112
113
  input,
113
- });
114
+ };
115
+ outputStates.set(event.output_index, state);
114
116
  stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
117
+ if (options?.grammarToolInputProperties?.has(currentBlock.name)) updateCustomInput(state, input, false, output, stream, options);
115
118
  }
116
119
  } else if (event.type === "response.reasoning_summary_part.added") {
117
120
  const state = outputStates.get(event.output_index);
@@ -193,11 +196,12 @@ export async function processResponsesStream<TApi extends Api>(
193
196
  const customEvent = event as unknown as { output_index?: number; item_id?: string; call_id?: string; delta?: string };
194
197
  const state = findCustomToolCallState(customEvent);
195
198
  if (state && typeof customEvent.delta === "string") {
196
- state.input += customEvent.delta;
197
- state.block.partialInput = state.input;
198
- state.block.arguments = { input: state.input };
199
- stream.push({ type: "toolcall_delta", contentIndex: state.blockIndex, delta: customEvent.delta, partial: output });
199
+ updateCustomInput(state, state.input + customEvent.delta, false, output, stream, options);
200
200
  }
201
+ } else if ((event as { type?: string }).type === "response.custom_tool_call_input.done") {
202
+ const customEvent = event as unknown as { output_index?: number; item_id?: string; call_id?: string; input?: string };
203
+ const state = findCustomToolCallState(customEvent);
204
+ if (state && typeof customEvent.input === "string") updateCustomInput(state, customEvent.input, true, output, stream, options);
201
205
  } else if (event.type === "response.function_call_arguments.delta") {
202
206
  const state = outputStates.get(event.output_index);
203
207
  if (state?.kind === "function_call") {
@@ -309,9 +313,8 @@ export async function processResponsesStream<TApi extends Api>(
309
313
  const thoughtSignature = encodeToolNamespaceSignature(customItem.namespace, customItem.name);
310
314
  const toolCall = state
311
315
  ? (() => {
312
- state.input = input;
313
316
  state.block.name = localToolName(customItem.namespace, customItem.name);
314
- state.block.arguments = { input };
317
+ updateCustomInput(state, input, true, output, stream, options);
315
318
  if (thoughtSignature) state.block.thoughtSignature = thoughtSignature;
316
319
  delete state.block.partialInput;
317
320
  return state.block;
@@ -321,7 +324,7 @@ export async function processResponsesStream<TApi extends Api>(
321
324
  type: "toolCall",
322
325
  id: `${customItem.call_id}|${customItemId(customItem.id, customItem.call_id)}`,
323
326
  name: localToolName(customItem.namespace, customItem.name),
324
- arguments: { input },
327
+ arguments: customArguments(localToolName(customItem.namespace, customItem.name), input, options),
325
328
  ...(thoughtSignature ? { thoughtSignature } : {}),
326
329
  };
327
330
  pushResponseBlock(fallbackToolCall, event.output_index);
@@ -11,10 +11,11 @@ export function shortHash(str: string): string {
11
11
  return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36);
12
12
  }
13
13
 
14
- export function parseStreamingJson(partialJson: string): Record<string, unknown> {
14
+ export function parseStreamingJson(partialJson: string): JsonObject {
15
15
  if (!partialJson || partialJson.trim() === "") return {};
16
16
  try {
17
- return JSON.parse(partialJson) as Record<string, unknown>;
17
+ const value: unknown = JSON.parse(partialJson);
18
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : {};
18
19
  } catch {
19
20
  return {};
20
21
  }
@@ -30,3 +31,4 @@ export function localToolName(namespace: unknown, name: string): string {
30
31
  if (namespace === "image_gen" && name === "imagegen") return "image_generation";
31
32
  return name;
32
33
  }
34
+ import type { JsonObject } from "@earendil-works/pi-ai";
@@ -1,14 +1,19 @@
1
1
  import { type Tool } from "@earendil-works/pi-ai";
2
2
  import { type Tool as OpenAITool } from "openai/resources/responses/responses.js";
3
3
  import { type ConvertResponsesToolsOptions } from "./types.js";
4
+ import { resolveGrammarSampling } from "./sampling.js";
4
5
 
5
6
  export function convertResponsesTools(tools: Tool[], options?: ConvertResponsesToolsOptions): OpenAITool[] {
6
7
  const strict = options?.strict === undefined ? false : options.strict;
7
- return tools.map((tool) => ({
8
- type: "function",
9
- name: tool.name,
10
- description: tool.description,
11
- parameters: tool.parameters as unknown as Record<string, unknown>,
12
- strict,
13
- }));
8
+ return tools.map((tool) => {
9
+ const grammar = resolveGrammarSampling(tool, options?.supportsOpenAIGrammarTools === true);
10
+ if (grammar) return {
11
+ type: "custom", name: tool.name, description: tool.description,
12
+ format: { type: "grammar", syntax: grammar.format, definition: grammar.definition },
13
+ };
14
+ return {
15
+ type: "function", name: tool.name, description: tool.description,
16
+ parameters: tool.parameters as unknown as Record<string, unknown>, strict,
17
+ };
18
+ });
14
19
  }
@@ -1,6 +1,7 @@
1
1
  import type { AssistantMessage } from "@earendil-works/pi-ai";
2
2
  import { type Context, type Usage } from "@earendil-works/pi-ai";
3
3
  import { type ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
4
+ import type { GrammarInputBuffer } from "./sampling.js";
4
5
 
5
6
  type MessageRole = Context["messages"][number]["role"];
6
7
 
@@ -52,6 +53,7 @@ export interface ReplayableResponseMessageItem {
52
53
  export type InternalAssistantContent = Extract<Message, { role: "assistant" }>["content"][number] | ImageGenerationCallBlock;
53
54
 
54
55
  export interface OpenAIResponsesStreamOptions {
56
+ grammarToolInputProperties?: ReadonlyMap<string, string>;
55
57
  serviceTier?: ResponseCreateParamsStreaming["service_tier"];
56
58
  resolveServiceTier?: (
57
59
  responseServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
@@ -66,10 +68,12 @@ export type TextSignaturePhase = "commentary" | "final_answer";
66
68
 
67
69
  export interface ConvertResponsesMessagesOptions {
68
70
  includeSystemPrompt?: boolean;
71
+ grammarToolInputProperties?: ReadonlyMap<string, string>;
69
72
  }
70
73
 
71
74
  export interface ConvertResponsesToolsOptions {
72
75
  strict?: boolean | null;
76
+ supportsOpenAIGrammarTools?: boolean;
73
77
  }
74
78
 
75
79
  export type ThinkingBlock = Extract<AssistantMessage["content"][number], { type: "thinking" }>;
@@ -108,6 +112,7 @@ export type CustomToolCallState = {
108
112
  sourceItemId?: string;
109
113
  callId: string;
110
114
  input: string;
115
+ inputJson?: GrammarInputBuffer;
111
116
  };
112
117
 
113
118
  export type OutputState = ReasoningState | MessageState | FunctionCallState | CustomToolCallState;
@@ -8,6 +8,7 @@ import {
8
8
  import { installCodexIdentityLifecycle } from "./codex-identity-extension.js";
9
9
  import { loadModelSettings } from "./model-catalog/runtime.js";
10
10
  import { loadSettings } from "./settings.js";
11
+ import { codeModeOwner, disposeCodeModeOwner, OWNER_CHANGED } from "./code-mode-owner.js";
11
12
 
12
13
  function enableDefinitions(broker: CodexBroker) {
13
14
  for (const tool of broker.tools.values()) {
@@ -37,10 +38,15 @@ export function ensureCodexServices(pi: ExtensionAPI): CodexBroker {
37
38
  for (const [name, index] of [...suppressed].sort(([, a], [, b]) => a - b)) {
38
39
  if (!active.includes(name)) active.splice(Math.min(index, active.length), 0, name);
39
40
  }
40
- suppressed.clear();
41
41
  return active;
42
42
  };
43
- const sync = (ctx: ExtensionContext) => {
43
+ let latest: ExtensionContext | undefined;
44
+ let syncing = false;
45
+ const sync = (ctx: ExtensionContext, fromTree = false) => {
46
+ latest = ctx;
47
+ if (syncing) return;
48
+ syncing = true;
49
+ try {
44
50
  const settings = loadSettings(ctx.cwd);
45
51
  const available = settings.enabled && hasConfiguredModelsLoaded(ctx, settings);
46
52
  if (available) enableDefinitions(broker);
@@ -48,42 +54,78 @@ export function ensureCodexServices(pi: ExtensionAPI): CodexBroker {
48
54
  const active = new Set(current);
49
55
  const capabilities = computeToolCapabilities(ctx.model as ModelLike | undefined, settings);
50
56
  const model = loadModelSettings(ctx.model as ModelLike | undefined, ctx.cwd, settings);
57
+ const controls = new Map<string, NonNullable<ReturnType<typeof codeModeOwner>>>();
51
58
  for (const name of PACKAGE_TOOL_NAMES) {
52
59
  const owned = broker.tools.get(name);
53
60
  if (!owned) continue; // Other extensions retain ownership of uninstalled names.
61
+ const control = codeModeOwner(pi, name, owned, broker.codeModeDefinitions?.get(name));
62
+ if (owned.codeModeOwner?.replaced) continue;
63
+ if (control) {
64
+ controls.set(name, control);
65
+ if (control.activeIntent === undefined) continue; // foreign replacement
66
+ if (control.activeIntent) active.add(name); else active.delete(name);
67
+ }
54
68
  const hostedWithoutCore = !broker.coreEnabled && (
55
69
  (name === "web_search" && model.webSearchImplementation === "hosted")
56
70
  || (name === "image_generation" && model.imageGenerationImplementation === "hosted" && !settings.directImageApiFallback)
57
71
  );
58
72
  const desired = available && owned.registered && capabilities[name].enabled && !hostedWithoutCore;
59
73
  if (!desired) active.delete(name);
60
- else if (settings.autoEnable) active.add(name);
74
+ else if (settings.autoEnable && !(fromTree && control?.activeIntent !== undefined)) active.add(name);
61
75
  }
62
- if (broker.tools.has("apply_patch") && active.has("apply_patch")) {
76
+ const ownsPatch = broker.tools.has("apply_patch") && !broker.tools.get("apply_patch")?.codeModeOwner?.replaced;
77
+ if (ownsPatch && active.has("apply_patch")) {
63
78
  for (const name of NATIVE_MUTATION_TOOL_NAMES) {
64
79
  if (active.delete(name) && !suppressed.has(name)) suppressed.set(name, current.indexOf(name));
65
80
  }
66
81
  }
67
- const next = current.filter(name => active.has(name));
68
- for (const name of active) if (!next.includes(name)) next.push(name);
69
- if (!broker.tools.has("apply_patch") || !active.has("apply_patch")) restore(next);
82
+ const physical = new Set(active);
83
+ for (const [name, control] of controls) {
84
+ const projected = control.projectActive(active.has(name));
85
+ if (projected === undefined) { if (current.includes(name)) physical.add(name); else physical.delete(name); }
86
+ else if (projected) physical.add(name); else physical.delete(name);
87
+ }
88
+ const next = current.filter(name => physical.has(name));
89
+ for (const name of physical) if (!next.includes(name)) next.push(name);
90
+ if (!ownsPatch || !active.has("apply_patch")) restore(next);
70
91
  if (next.join("\0") !== current.join("\0")) pi.setActiveTools(next);
92
+ if (!ownsPatch || !active.has("apply_patch")) suppressed.clear();
93
+ for (const control of controls.values()) control.reconcile();
94
+ } finally { syncing = false; }
71
95
  };
96
+ const offOwner = pi.events.on(OWNER_CHANGED, (message) => {
97
+ if ((message as { version?: number })?.version === 1 && latest && !broker.closed) sync(latest);
98
+ });
72
99
  pi.on("session_start", (_event, ctx) => {
73
100
  broker.presentation.clear();
101
+ suppressed.clear();
74
102
  sync(ctx);
75
103
  });
76
104
  pi.on("model_select", (_event, ctx) => sync(ctx));
77
105
  pi.on("thinking_level_select", (_event, ctx) => sync(ctx));
106
+ pi.on("session_tree", (_event, ctx) => {
107
+ // Restoration receipts belong to the previous physical loadout, not
108
+ // to tools absent from the newly selected branch.
109
+ suppressed.clear();
110
+ sync(ctx, true);
111
+ });
78
112
  pi.on("agent_end", () => broker.presentation.scheduleFlush());
79
113
  pi.on("session_shutdown", () => {
80
- try { broker.presentation.flush(); }
81
- finally {
82
- broker.presentation.clear();
114
+ offOwner();
115
+ const errors: unknown[] = [];
116
+ for (const tool of broker.tools.values()) if (tool.codeModeOwner) tool.codeModeOwner.closed = true;
117
+ for (const tool of broker.tools.values()) {
118
+ try { disposeCodeModeOwner(tool); } catch (error) { errors.push(error); }
119
+ }
120
+ try { broker.presentation.flush(); } catch (error) { errors.push(error); }
121
+ try { broker.presentation.clear(); } catch (error) { errors.push(error); }
122
+ try {
83
123
  const current = pi.getActiveTools?.() ?? [];
84
124
  const next = restore([...current]);
85
125
  if (next.join("\0") !== current.join("\0")) pi.setActiveTools(next);
86
- }
126
+ suppressed.clear();
127
+ } catch (error) { errors.push(error); }
128
+ if (errors.length) throw new AggregateError(errors, "Codex owner shutdown failed");
87
129
  });
88
130
  return broker;
89
131
  }