zidane 6.6.3 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist/{acp-BQqhozmt.js → acp-Wr8l4IDI.js} +7 -180
  2. package/dist/acp-Wr8l4IDI.js.map +1 -0
  3. package/dist/acp-cli.js +6 -6
  4. package/dist/acp.d.ts +1 -1
  5. package/dist/acp.d.ts.map +1 -1
  6. package/dist/acp.js +2 -1
  7. package/dist/{agent-DTxH7RHW.js → agent-C-camKQW.js} +162 -12
  8. package/dist/agent-C-camKQW.js.map +1 -0
  9. package/dist/agent.d.ts +1 -1
  10. package/dist/agent.js +1 -1
  11. package/dist/{anthropic-DRNRnd6W.js → anthropic-BDOGzhtj.js} +3 -3
  12. package/dist/{anthropic-DRNRnd6W.js.map → anthropic-BDOGzhtj.js.map} +1 -1
  13. package/dist/{transcript-anchors-BjH-68k2.js → approvals-CqqxE5Vh.js} +4924 -275
  14. package/dist/approvals-CqqxE5Vh.js.map +1 -0
  15. package/dist/{auth-7rv6ZfWk.js → auth-BdwDROYg.js} +12 -8
  16. package/dist/{auth-7rv6ZfWk.js.map → auth-BdwDROYg.js.map} +1 -1
  17. package/dist/chat/pure.d.ts +221 -4
  18. package/dist/chat/pure.d.ts.map +1 -0
  19. package/dist/chat/pure.js +4 -2
  20. package/dist/chat.d.ts +6 -2911
  21. package/dist/chat.js +8 -261
  22. package/dist/{context-transport-D8yeGdpd.js → context-transport-BqYwzJYE.js} +2 -2
  23. package/dist/{context-transport-D8yeGdpd.js.map → context-transport-BqYwzJYE.js.map} +1 -1
  24. package/dist/contexts/daytona.d.ts +2 -1
  25. package/dist/contexts/daytona.d.ts.map +1 -1
  26. package/dist/contexts/daytona.js +14 -3
  27. package/dist/contexts/daytona.js.map +1 -1
  28. package/dist/contexts/e2b.d.ts +1 -1
  29. package/dist/contexts/e2b.js +1 -1
  30. package/dist/daemon-cli.d.ts +1 -0
  31. package/dist/daemon-cli.js +90 -0
  32. package/dist/daemon-cli.js.map +1 -0
  33. package/dist/eval.d.ts +1 -1
  34. package/dist/eval.js +1 -1
  35. package/dist/extensions.d.ts +2 -2
  36. package/dist/extensions.js +2 -2
  37. package/dist/headless.d.ts +1 -1
  38. package/dist/headless.js +4 -4
  39. package/dist/{index-D0O-QO7Z.d.ts → index-B-FSaM3_.d.ts} +2863 -2375
  40. package/dist/index-B-FSaM3_.d.ts.map +1 -0
  41. package/dist/index-DgdzXRPH.d.ts +3590 -0
  42. package/dist/index-DgdzXRPH.d.ts.map +1 -0
  43. package/dist/index.d.ts +2 -2
  44. package/dist/index.js +14 -1179
  45. package/dist/ipc-CIv87xuH.js +331 -0
  46. package/dist/ipc-CIv87xuH.js.map +1 -0
  47. package/dist/ipc.d.ts +849 -0
  48. package/dist/ipc.d.ts.map +1 -0
  49. package/dist/ipc.js +4 -0
  50. package/dist/json-rpc-rSWMUh9b.js +192 -0
  51. package/dist/json-rpc-rSWMUh9b.js.map +1 -0
  52. package/dist/{logger-DBX9uYOw.js → logger--C1VQjwJ.js} +3 -13
  53. package/dist/logger--C1VQjwJ.js.map +1 -0
  54. package/dist/logs-view-CWG8aUAG.js +44 -0
  55. package/dist/logs-view-CWG8aUAG.js.map +1 -0
  56. package/dist/{mcp-CVtSQTWg.js → mcp-BIWa57eY.js} +3 -3
  57. package/dist/{mcp-CVtSQTWg.js.map → mcp-BIWa57eY.js.map} +1 -1
  58. package/dist/mcp.d.ts +1 -1
  59. package/dist/mcp.js +1 -1
  60. package/dist/{messages-CZMUuKzo.js → messages-21XfHxyF.js} +3 -3
  61. package/dist/{messages-CZMUuKzo.js.map → messages-21XfHxyF.js.map} +1 -1
  62. package/dist/{openai-compat-BoTi-9fd.js → openai-compat-DY-AKxCg.js} +2 -2
  63. package/dist/{openai-compat-BoTi-9fd.js.map → openai-compat-DY-AKxCg.js.map} +1 -1
  64. package/dist/output/stream-json.d.ts +1 -1
  65. package/dist/output/terminal.d.ts +1 -1
  66. package/dist/{plugins-onDvTHEm.js → plugins-B531Tz5b.js} +2 -2
  67. package/dist/{plugins-onDvTHEm.js.map → plugins-B531Tz5b.js.map} +1 -1
  68. package/dist/plugins.d.ts +1 -1
  69. package/dist/plugins.js +1 -1
  70. package/dist/presets.d.ts +1 -1
  71. package/dist/presets.js +1 -1
  72. package/dist/providers/anthropic.d.ts +1 -1
  73. package/dist/providers/anthropic.js +1 -1
  74. package/dist/providers/arcee.d.ts +1 -1
  75. package/dist/providers/arcee.js +1 -1
  76. package/dist/providers/baseten.d.ts +1 -1
  77. package/dist/providers/baseten.js +1 -1
  78. package/dist/providers/cerebras.d.ts +1 -1
  79. package/dist/providers/cerebras.js +1 -1
  80. package/dist/providers/local.d.ts +1 -1
  81. package/dist/providers/local.js +1 -1
  82. package/dist/providers/openai-compat.d.ts +1 -1
  83. package/dist/providers/openai-compat.js +1 -1
  84. package/dist/providers/openai.d.ts +1 -1
  85. package/dist/providers/openai.js +3 -3
  86. package/dist/providers/openrouter.d.ts +1 -1
  87. package/dist/providers/openrouter.js +1 -1
  88. package/dist/providers/xai.d.ts +1 -1
  89. package/dist/providers/xai.js +1 -1
  90. package/dist/{providers-CmhdtD7w.js → providers-DX91jwir.js} +2 -2
  91. package/dist/{providers-CmhdtD7w.js.map → providers-DX91jwir.js.map} +1 -1
  92. package/dist/providers.d.ts +1 -1
  93. package/dist/providers.js +3 -3
  94. package/dist/restate.d.ts +1 -1
  95. package/dist/restate.js +8 -8
  96. package/dist/restate.js.map +1 -1
  97. package/dist/server-DJqp9dIR.js +1317 -0
  98. package/dist/server-DJqp9dIR.js.map +1 -0
  99. package/dist/session/sqlite.d.ts +1 -1
  100. package/dist/session/sqlite.d.ts.map +1 -1
  101. package/dist/session/sqlite.js +42 -4
  102. package/dist/session/sqlite.js.map +1 -1
  103. package/dist/{session-CyRLSsVa.js → session-K3icbPH9.js} +8 -2
  104. package/dist/session-K3icbPH9.js.map +1 -0
  105. package/dist/session.d.ts +1 -1
  106. package/dist/session.js +2 -2
  107. package/dist/{skills-BMGH_KK1.js → skills-PJyiSRCp.js} +3 -21
  108. package/dist/skills-PJyiSRCp.js.map +1 -0
  109. package/dist/skills.d.ts +1 -1
  110. package/dist/skills.js +1 -1
  111. package/dist/src-DzzhE2YO.js +2461 -0
  112. package/dist/src-DzzhE2YO.js.map +1 -0
  113. package/dist/todo-edits-Dw2oO2ON.js +378 -0
  114. package/dist/todo-edits-Dw2oO2ON.js.map +1 -0
  115. package/dist/{tool-formatters-NrzD6Fkk.d.ts → tool-formatters-DO56YBBw.d.ts} +104 -4
  116. package/dist/tool-formatters-DO56YBBw.d.ts.map +1 -0
  117. package/dist/tools/fetch-url.d.ts +1 -1
  118. package/dist/tools/web-search.d.ts +1 -1
  119. package/dist/tools.d.ts +1 -1
  120. package/dist/tools.js +1 -1
  121. package/dist/{transcript-anchors-UhC58Tmj.d.ts → transcript-anchors-DXqWWGbq.d.ts} +53 -4
  122. package/dist/transcript-anchors-DXqWWGbq.d.ts.map +1 -0
  123. package/dist/tui.d.ts +94 -13
  124. package/dist/tui.d.ts.map +1 -1
  125. package/dist/tui.js +2241 -1009
  126. package/dist/tui.js.map +1 -1
  127. package/dist/{turn-operations-mMxDRgLY.js → turn-operations-CZLsNgw-.js} +896 -11
  128. package/dist/turn-operations-CZLsNgw-.js.map +1 -0
  129. package/dist/{turn-operations-Cuc9pwiB.d.ts → turn-operations-DKFYWP3O.d.ts} +501 -4
  130. package/dist/turn-operations-DKFYWP3O.d.ts.map +1 -0
  131. package/dist/{types-BIarq1qE.js → types-CWgENVLY.js} +24 -2
  132. package/dist/types-CWgENVLY.js.map +1 -0
  133. package/dist/types.d.ts +1 -1
  134. package/dist/types.js +1 -1
  135. package/dist/xml-D5HBhJle.js +22 -0
  136. package/dist/xml-D5HBhJle.js.map +1 -0
  137. package/docs/CHAT.md +39 -0
  138. package/docs/IPC.md +420 -0
  139. package/docs/TUI.md +62 -0
  140. package/package.json +8 -2
  141. package/dist/acp-BQqhozmt.js.map +0 -1
  142. package/dist/agent-DTxH7RHW.js.map +0 -1
  143. package/dist/chat.d.ts.map +0 -1
  144. package/dist/chat.js.map +0 -1
  145. package/dist/index-D0O-QO7Z.d.ts.map +0 -1
  146. package/dist/index.js.map +0 -1
  147. package/dist/inline-autocomplete-D0MPvBaS.js +0 -596
  148. package/dist/inline-autocomplete-D0MPvBaS.js.map +0 -1
  149. package/dist/logger-DBX9uYOw.js.map +0 -1
  150. package/dist/login-CB23LyI1.js +0 -707
  151. package/dist/login-CB23LyI1.js.map +0 -1
  152. package/dist/session-CyRLSsVa.js.map +0 -1
  153. package/dist/skills-BMGH_KK1.js.map +0 -1
  154. package/dist/tool-formatters-NrzD6Fkk.d.ts.map +0 -1
  155. package/dist/transcript-anchors-BjH-68k2.js.map +0 -1
  156. package/dist/transcript-anchors-UhC58Tmj.d.ts.map +0 -1
  157. package/dist/turn-operations-Cuc9pwiB.d.ts.map +0 -1
  158. package/dist/turn-operations-mMxDRgLY.js.map +0 -1
  159. package/dist/types-BIarq1qE.js.map +0 -1
@@ -0,0 +1,2461 @@
1
+ import { t as defineSkill } from "./skills-PJyiSRCp.js";
2
+ import { h as McpOAuthProvider, m as sseToJsonFetchIfNeeded, o as expandMcpConfigEnv, p as createTolerantClient, v as isOAuthTokenRejectionError } from "./mcp-BIWa57eY.js";
3
+ import { createRequire } from "node:module";
4
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { existsSync, readdirSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+ import { homedir } from "node:os";
8
+ import { createServer } from "node:http";
9
+ //#region src/behaviors/local-reliability.ts
10
+ const DEFAULT_LOCAL_RELIABILITY = {
11
+ maxConcurrentTools: 3,
12
+ readFileDefaults: {
13
+ limit: 400,
14
+ maxBytes: 64 * 1024
15
+ },
16
+ toolOutputBudget: 32 * 1024,
17
+ textToolCallRescue: { mode: "steer" },
18
+ thinkingOverrunPolicy: {
19
+ mode: "steer-next-turn",
20
+ thresholdTokens: 2048
21
+ }
22
+ };
23
+ /**
24
+ * Opt-in behavior overlay for brittle/local OpenAI-compatible endpoints.
25
+ * Returns plain `AgentBehavior` so hosts can spread it before or after their
26
+ * own overrides; no built-in profile uses this helper by default.
27
+ */
28
+ function localReliabilityBehavior(overrides = {}) {
29
+ return {
30
+ ...DEFAULT_LOCAL_RELIABILITY,
31
+ ...overrides,
32
+ readFileDefaults: {
33
+ ...DEFAULT_LOCAL_RELIABILITY.readFileDefaults,
34
+ ...overrides.readFileDefaults ?? {}
35
+ },
36
+ textToolCallRescue: overrides.textToolCallRescue ?? DEFAULT_LOCAL_RELIABILITY.textToolCallRescue,
37
+ thinkingOverrunPolicy: overrides.thinkingOverrunPolicy ?? DEFAULT_LOCAL_RELIABILITY.thinkingOverrunPolicy
38
+ };
39
+ }
40
+ //#endregion
41
+ //#region src/chat/commands.ts
42
+ /**
43
+ * Persisted form of output blocks inside a display-only `command` turn:
44
+ * plain blocks → `text` content; `detail` blocks → `tool_result` content
45
+ * with the label/syntax carried in namespaced metadata (rebuilt into the
46
+ * collapsed rendering on reload). Shared by the command emit flush and
47
+ * `ctx.appendNote` so both persist identically.
48
+ */
49
+ function commandBlocksToContent(blocks) {
50
+ return blocks.map((block) => {
51
+ if (!block.detail && !block.custom) return {
52
+ type: "text",
53
+ text: block.text
54
+ };
55
+ const commandMeta = block.custom ? { custom: {
56
+ renderer: block.custom.renderer,
57
+ ...block.custom.data !== void 0 ? { data: block.custom.data } : {}
58
+ } } : {
59
+ ...block.detail?.label ? { label: block.detail.label } : {},
60
+ ...block.detail?.syntax ? { syntax: block.detail.syntax } : {}
61
+ };
62
+ return {
63
+ type: "tool_result",
64
+ callId: `command_detail_${crypto.randomUUID()}`,
65
+ output: block.text,
66
+ ...Object.keys(commandMeta).length > 0 ? { metadata: { command: commandMeta } } : {}
67
+ };
68
+ });
69
+ }
70
+ /**
71
+ * Map ONE command-output block to its LIVE transcript event — the
72
+ * in-memory mirror of {@link commandBlocksToContent}'s persisted shape
73
+ * (whose replay side lives in `eventsFromTurns`). One definition shared
74
+ * by the command emit flush and the `ctx.appendNote` host impl so live
75
+ * rendering can never drift from reloaded rendering — the exact drift
76
+ * that shipped `custom` blocks rendering only after a reload.
77
+ *
78
+ * Precedence matches the persisted side: `custom` > `detail` > markdown.
79
+ */
80
+ function commandBlockToEvent(block) {
81
+ if (block.custom) return {
82
+ kind: "custom",
83
+ text: block.text,
84
+ custom: {
85
+ renderer: block.custom.renderer,
86
+ ...block.custom.data !== void 0 ? { data: block.custom.data } : {}
87
+ }
88
+ };
89
+ if (block.detail) return {
90
+ kind: "command-detail",
91
+ text: block.text,
92
+ ...block.detail.label ? { label: block.detail.label } : {},
93
+ ...block.detail.syntax ? { syntax: block.detail.syntax } : {}
94
+ };
95
+ return {
96
+ kind: "markdown",
97
+ text: block.text,
98
+ streaming: false
99
+ };
100
+ }
101
+ /** Normalize a command to its ordered step list (`steps` wins over `run`). */
102
+ function commandSteps(command) {
103
+ if (command.steps && command.steps.length > 0) return command.steps;
104
+ if (command.run) return [{ run: command.run }];
105
+ return [];
106
+ }
107
+ /**
108
+ * Parse a prompt as a command invocation, but only when the command token is
109
+ * the LEADING token of the whole prompt and names a known command. This is
110
+ * what keeps mid-prompt `/skill` mentions routed to the skills provider:
111
+ * `"summarize /research"` is not a command, `"/research foo"` is.
112
+ *
113
+ * `known` is matched case-insensitively; entries may be bare names or
114
+ * `{ name, aliases }` records — an alias hit canonicalizes to the entry's
115
+ * `name` (registered casing), so dispatch-by-name downstream just works.
116
+ */
117
+ function parseCommandInvocation(prompt, known) {
118
+ const body = prompt.trimStart();
119
+ if (body[0] !== "/") return null;
120
+ const afterSlash = body.slice(1);
121
+ const wsIdx = afterSlash.search(/\s/);
122
+ const typed = (wsIdx === -1 ? afterSlash : afterSlash.slice(0, wsIdx)).toLowerCase();
123
+ if (typed.length === 0) return null;
124
+ let canonical;
125
+ for (const entry of known) {
126
+ const name = typeof entry === "string" ? entry : entry.name;
127
+ if (name.toLowerCase() === typed) {
128
+ canonical = name;
129
+ break;
130
+ }
131
+ if (typeof entry !== "string" && entry.aliases?.some((a) => a.toLowerCase() === typed)) {
132
+ canonical = name;
133
+ break;
134
+ }
135
+ }
136
+ if (canonical === void 0) return null;
137
+ const args = (wsIdx === -1 ? "" : afterSlash.slice(wsIdx + 1)).trim();
138
+ return {
139
+ name: canonical,
140
+ invokedAs: typed,
141
+ args,
142
+ argv: args.length > 0 ? args.split(/\s+/) : []
143
+ };
144
+ }
145
+ /**
146
+ * Run a command's steps in order, threading `ctx.data`. Stops at the first
147
+ * step that throws (error isolated + returned, not rethrown) or when the
148
+ * signal aborts. Pure orchestration — all I/O lives in the host-supplied
149
+ * `ctx` capabilities.
150
+ */
151
+ async function executeCommand(command, ctx) {
152
+ const steps = commandSteps(command);
153
+ let ranSteps = 0;
154
+ for (const step of steps) {
155
+ if (ctx.signal.aborted) return {
156
+ ok: false,
157
+ ranSteps,
158
+ aborted: true
159
+ };
160
+ ranSteps++;
161
+ try {
162
+ const result = await step.run(ctx);
163
+ if (result && result.data) Object.assign(ctx.data, result.data);
164
+ } catch (error) {
165
+ if (ctx.signal.aborted) return {
166
+ ok: false,
167
+ ranSteps,
168
+ aborted: true
169
+ };
170
+ return {
171
+ ok: false,
172
+ ranSteps,
173
+ error
174
+ };
175
+ }
176
+ }
177
+ return {
178
+ ok: true,
179
+ ranSteps
180
+ };
181
+ }
182
+ //#endregion
183
+ //#region src/zod.ts
184
+ function isStandardSchema(value) {
185
+ if (typeof value !== "object" || value === null) return false;
186
+ const marker = value["~standard"];
187
+ return typeof marker === "object" && marker !== null && typeof marker.vendor === "string";
188
+ }
189
+ /**
190
+ * Resolve a {@link SchemaInput} to the plain JSON Schema object providers
191
+ * expect. Zod schemas convert via the lazily-imported `z.toJSONSchema`
192
+ * (v4 native); other Standard Schema vendors are rejected with a clear
193
+ * error — conversion is vendor-specific and only zod is wired today.
194
+ */
195
+ async function resolveSchemaInput(schema) {
196
+ if (!isStandardSchema(schema)) return schema;
197
+ const vendor = schema["~standard"].vendor;
198
+ if (vendor !== "zod") throw new Error(`unsupported schema vendor "${vendor}" — pass a zod v4 schema or a plain JSON Schema object`);
199
+ let toJSONSchema;
200
+ try {
201
+ toJSONSchema = (await import("zod")).z.toJSONSchema;
202
+ } catch {
203
+ throw new Error("a zod schema was passed but the \"zod\" package could not be imported — install zod >= 4");
204
+ }
205
+ return zodToJsonSchema(toJSONSchema(schema));
206
+ }
207
+ /**
208
+ * Normalize a JSON Schema (e.g. from zod v4's z.toJSONSchema()) for use
209
+ * as a ToolSpec.inputSchema.
210
+ *
211
+ * Strips the $schema key that some providers reject.
212
+ */
213
+ function zodToJsonSchema(jsonSchema) {
214
+ const { $schema, ...rest } = jsonSchema;
215
+ return rest;
216
+ }
217
+ //#endregion
218
+ //#region src/chat/generate-title.ts
219
+ /** Hard cap on the result length. Anything longer is truncated client-side. */
220
+ const TITLE_MAX_CHARS = 60;
221
+ /** Default turn count fed to the model. 10 covers most exchanges without bloat. */
222
+ const DEFAULT_MAX_TURNS = 10;
223
+ /** Tokens budgeted for the model's reply. 64 fits any sane title with headroom. */
224
+ const TITLE_RESPONSE_MAX_TOKENS = 64;
225
+ /** Per-turn body cap when assembling the prompt — keeps the request payload tight. */
226
+ const PROMPT_TURN_CHAR_MAX = 2e3;
227
+ /**
228
+ * System prompt for the titling call.
229
+ *
230
+ * Written as a LABELING task, not a chat turn: handed a bare "generate a
231
+ * title for this conversation" instruction with the transcript inline, a
232
+ * cheap model reads the trailing assistant message and just keeps
233
+ * talking — observed in the wild as a session titled `I don't have a
234
+ * "plan tool" available in this conversation…`, i.e. the model answering
235
+ * the last user turn instead of naming it. The rules below say what the
236
+ * model IS (an annotator, not a participant), and the examples show the
237
+ * shape — including the exact failure case (a transcript whose last turn
238
+ * is a refusal) so the answer-shaped completion has a labeled
239
+ * counter-example to copy instead.
240
+ */
241
+ const TITLE_SYSTEM_PROMPT = [
242
+ "You label conversations. Each input is a transcript of a session between a user and a coding agent.",
243
+ "Name what the session is ABOUT. You are an annotator, never a participant in the conversation.",
244
+ "",
245
+ "Rules:",
246
+ "- Reply with ONLY the label: no quotes, no markdown, no trailing punctuation, no preamble.",
247
+ `- 3 to 7 words, under ${TITLE_MAX_CHARS} characters, written as a noun phrase or an imperative.`,
248
+ "- Name the user's topic or task using concrete nouns from the transcript: files, tools, features, errors.",
249
+ "- NEVER answer, continue, or comment on the conversation — not even when the last message is a question, a refusal, or an error.",
250
+ "- No first person (\"I\", \"we\"), no \"the user\", and no filler words like conversation, session, chat, request, discussion, help.",
251
+ "",
252
+ "Examples of a transcript and its label:",
253
+ "- user asks why `bun test` hangs after an upgrade, agent finds a stuck worker → Bun test hangs on stuck worker",
254
+ "- user asks for a --json flag on the CLI → Add JSON output flag to the CLI",
255
+ "- user asks about a plan tool, agent replies that no such tool exists → Missing plan tool in the agent toolset"
256
+ ].join("\n");
257
+ /**
258
+ * Openers that mark a model reply rather than a label — the model
259
+ * answering the transcript instead of naming it. Treated as a failed
260
+ * generation (see {@link cleanTitle}) so the auto-titler retries on the
261
+ * next run rather than persisting a sentence as the session name.
262
+ *
263
+ * Deliberately conservative: only phrasings that a real title would
264
+ * never start with. Excluded on purpose because they head perfectly good
265
+ * titles: `no` / `there is` ("No JSON output in the CLI"), `ok` / `yes` /
266
+ * `great` ("OK button never fires", "Yes/no confirm regression").
267
+ *
268
+ * The pronoun cases require FOLLOWING WHITESPACE rather than a word
269
+ * boundary — `\b` after `i` also matches punctuation, which rejected
270
+ * "I/O timeout in the docker context". Contractions are matched
271
+ * explicitly instead, with both the straight and typographic apostrophe
272
+ * (models emit U+2019 freely, and `here's` / `let's` have no bare-word
273
+ * alternative to fall back on).
274
+ */
275
+ const ANSWER_SHAPED_OPENER = /^(?:i\s|i['’](?:m|ve|ll|d)\b|we\s|we['’](?:re|ve|ll)\b|let['’]s\b|let\s+(?:me|us)\b|sure\b|certainly\b|okay\b|sorry\b|thanks\b|unfortunately\b|here['’]s\b|here\s+is\b|as\s+an\b|you\s+(?:can|should|need)\b|it\s+(?:looks|seems|appears)\b)/i;
276
+ /**
277
+ * Drive the provider's `stream()` once and return a concise title for
278
+ * the conversation represented by `turns`. Throws when:
279
+ * - `turns` contains no text-bearing content (nothing to summarize),
280
+ * - the provider stream completes with no output text,
281
+ * - the model answered the transcript instead of labeling it (see
282
+ * {@link cleanTitle}) — a failed generation the caller retries,
283
+ * - `signal` is aborted (rethrown verbatim).
284
+ *
285
+ * Output is trimmed, single-line, with surrounding quotes / trailing
286
+ * punctuation stripped, and truncated to `TITLE_MAX_CHARS`.
287
+ */
288
+ async function generateSessionTitle({ provider, model, turns, maxTurns = DEFAULT_MAX_TURNS, signal }) {
289
+ const transcript = renderTitleTranscript(turns, maxTurns);
290
+ if (!transcript) throw new Error("No text content in the recent turns to summarize.");
291
+ const userPrompt = [
292
+ "<transcript>",
293
+ transcript,
294
+ "</transcript>",
295
+ "",
296
+ "Write the label for the session in the transcript above. Label only."
297
+ ].join("\n");
298
+ let text = "";
299
+ const result = await provider.stream({
300
+ model,
301
+ system: TITLE_SYSTEM_PROMPT,
302
+ tools: [],
303
+ messages: [provider.userMessage(userPrompt)],
304
+ maxTokens: TITLE_RESPONSE_MAX_TOKENS,
305
+ ...signal ? { signal } : {}
306
+ }, { onText: (delta) => {
307
+ text += delta;
308
+ } });
309
+ return cleanTitle(text.trim().length > 0 ? text : result.text);
310
+ }
311
+ /**
312
+ * Decide whether a session is ready for one-shot automatic titling.
313
+ *
314
+ * Returns `true` iff the conversation has had its first exchange — at
315
+ * least two turns, i.e. the opening user message plus the assistant's
316
+ * reply (this is exactly the "N turns" count the sessions list shows) —
317
+ * AND no explicit title has been set yet. An existing `metadata.title`
318
+ * (a previous auto-generation, or a manual title from the session-details
319
+ * "generate title" action) is never clobbered.
320
+ *
321
+ * Pure + total. The runtime layers its own "is one already in flight?"
322
+ * and "is a provider picked?" guards on top of this; the predicate only
323
+ * encodes the content-level rule so it can be pinned in tests without a
324
+ * mock provider — same rationale as the exported {@link cleanTitle}.
325
+ */
326
+ function shouldAutoGenerateTitle(turns, metadata) {
327
+ if ((typeof metadata?.title === "string" ? metadata.title.trim() : "").length > 0) return false;
328
+ return turns.length >= 2;
329
+ }
330
+ /**
331
+ * Assemble the transcript fed to the titling model: the session's
332
+ * OPENING user turn (when it falls outside the window) followed by the
333
+ * last `maxTurns` turns, separated by an `…` gap marker.
334
+ *
335
+ * The tail alone is not enough. A session's topic is set by the first
336
+ * thing the user asked; after a dozen turns that message is off the
337
+ * window and the model ends up naming whatever tangent the conversation
338
+ * was on when titling fired — or, worse, the assistant's last reply.
339
+ * Anchoring on the opening intent keeps the label about the session.
340
+ *
341
+ * `maxTurns` keeps its meaning (a cap on TRAILING turns); the anchor is
342
+ * one extra entry on top, and only exists when the window actually cut
343
+ * it off. Returns `''` when nothing in the selection carries text.
344
+ */
345
+ function renderTitleTranscript(turns, maxTurns) {
346
+ const cap = Math.max(1, maxTurns);
347
+ const tail = renderTurnsForPrompt(turns.slice(-cap));
348
+ const opening = turns.slice(0, Math.max(0, turns.length - cap)).find((turn) => turn.role === "user" && textBodyOf(turn).length > 0);
349
+ const anchor = opening ? renderTurnsForPrompt([opening]) : "";
350
+ if (anchor && tail) return `${anchor}\n\n…\n\n${tail}`;
351
+ return anchor || tail;
352
+ }
353
+ /**
354
+ * Compact a turn list into a transcript-style prompt:
355
+ *
356
+ * user: …
357
+ * assistant: …
358
+ * user: …
359
+ *
360
+ * Tool calls and tool results are dropped entirely — a cheap title model
361
+ * happily echoes plumbing like `[tool: list_files]` back as "the title"
362
+ * when a marker is the only assistant content it sees. The topical text
363
+ * is what a title needs. Per-turn text is clipped at `PROMPT_TURN_CHAR_MAX`
364
+ * so a single huge code dump doesn't blow the budget.
365
+ *
366
+ * Returns an empty string when the slice has no text-bearing content
367
+ * (e.g. a turn list that's only tool plumbing).
368
+ *
369
+ * Exported for reuse by other silent one-shot helpers (`generate-data`'s
370
+ * optional conversation context) so every side-channel inference compacts
371
+ * history the same way.
372
+ */
373
+ function renderTurnsForPrompt(turns) {
374
+ const lines = [];
375
+ for (const turn of turns) {
376
+ const body = textBodyOf(turn);
377
+ if (!body) continue;
378
+ const role = turn.role === "assistant" ? "assistant" : turn.role === "user" ? "user" : "system";
379
+ lines.push(`${role}: ${clip(body, PROMPT_TURN_CHAR_MAX)}`);
380
+ }
381
+ return lines.join("\n\n");
382
+ }
383
+ function textBodyOf(turn) {
384
+ const parts = [];
385
+ for (const block of turn.content) if (block.type === "text" && block.text.trim()) parts.push(block.text.trim());
386
+ return parts.join(" ");
387
+ }
388
+ /** Strip one layer of matched surrounding quotes (`"foo"`, `'foo'`, `` `foo` ``, `“foo”`). */
389
+ function unwrapQuotes(s) {
390
+ if (s.length < 2) return s;
391
+ const first = s.charAt(0);
392
+ const last = s.charAt(s.length - 1);
393
+ return first === "\"" && last === "\"" || first === "'" && last === "'" || first === "`" && last === "`" || first === "“" && last === "”" ? s.slice(1, -1).trim() : s;
394
+ }
395
+ /**
396
+ * Normalize a model-generated title into the shape we want to persist:
397
+ *
398
+ * - Collapse internal whitespace + take the first line only (some
399
+ * models emit "Title: foo\n\nReason: …" despite instructions).
400
+ * - Strip surrounding quotes, a leading `Title:` / `title -` preamble,
401
+ * and a `user:` / `assistant:` role prefix echoed from the transcript
402
+ * — repeatedly, in any nesting order.
403
+ * - Reject placeholders and answer-shaped replies (see
404
+ * {@link ANSWER_SHAPED_OPENER}) by throwing.
405
+ * - Strip trailing punctuation (`.`, `!`, `?`) — titles read cleaner
406
+ * without it.
407
+ * - Truncate to `TITLE_MAX_CHARS` with a trailing `…` when over.
408
+ *
409
+ * Exported as `cleanTitle` so tests can pin the normalization rules
410
+ * without going through a mock provider.
411
+ */
412
+ function cleanTitle(raw) {
413
+ let s = raw.split("\n")[0]?.trim() ?? "";
414
+ for (let pass = 0; pass < 4; pass++) {
415
+ const before = s;
416
+ s = unwrapQuotes(s);
417
+ s = s.replace(/^\s*title\s*[:\-–—]\s*/i, "");
418
+ s = s.replace(/^\s*(?:user|assistant|system)\s*:\s*/i, "");
419
+ if (s === before) break;
420
+ }
421
+ s = s.replace(/[.!?]+$/, "").trim();
422
+ if (s.length === 0) throw new Error("Model returned an empty title.");
423
+ if (/^\[.*\]$/.test(s)) throw new Error("Model returned a placeholder instead of a title.");
424
+ if (ANSWER_SHAPED_OPENER.test(s)) throw new Error("Model answered the conversation instead of titling it.");
425
+ return s.length > TITLE_MAX_CHARS ? `${s.slice(0, TITLE_MAX_CHARS - 1)}…` : s;
426
+ }
427
+ function clip(text, max) {
428
+ return text.length > max ? `${text.slice(0, max)}…` : text;
429
+ }
430
+ //#endregion
431
+ //#region src/chat/generate-data.ts
432
+ /** Synthetic forced-choice tool name (never persisted — the call is side-channel only). */
433
+ const DATA_TOOL_NAME = "__data__";
434
+ /** Default cap on conversation turns folded into the context block. */
435
+ const DEFAULT_CONTEXT_TURNS = 12;
436
+ async function generateStructuredData(options) {
437
+ const { provider, model, prompt, signal } = options;
438
+ const spec = {
439
+ name: DATA_TOOL_NAME,
440
+ description: "Return the result. The input of this call IS the answer — it must conform to the schema.",
441
+ inputSchema: await resolveSchemaInput(options.schema)
442
+ };
443
+ const contextTurns = options.turns && options.turns.length > 0 ? options.turns.slice(-(options.maxContextTurns ?? DEFAULT_CONTEXT_TURNS)) : [];
444
+ const transcript = contextTurns.length > 0 ? renderTurnsForPrompt(contextTurns) : "";
445
+ const userPrompt = transcript ? `<conversation>\n${transcript}\n</conversation>\n\n${prompt}` : prompt;
446
+ const payload = (await provider.stream({
447
+ model,
448
+ system: options.system ?? `You produce structured data. Call the ${DATA_TOOL_NAME} tool exactly once with the requested value. No prose.`,
449
+ tools: provider.formatTools([spec]),
450
+ messages: [provider.userMessage(userPrompt)],
451
+ toolChoice: {
452
+ type: "tool",
453
+ name: DATA_TOOL_NAME
454
+ },
455
+ ...signal ? { signal } : {}
456
+ }, { onText: () => {} })).toolCalls.find((tc) => tc.name === DATA_TOOL_NAME)?.input;
457
+ if (payload === void 0) throw new Error("generateStructuredData: the model returned no structured payload");
458
+ return payload;
459
+ }
460
+ //#endregion
461
+ //#region src/chat/inline-autocomplete.ts
462
+ /**
463
+ * Inline (ghost-text) autocomplete for the prompt.
464
+ *
465
+ * Distinct from the trigger-based completion popover (`@` files, `/`
466
+ * commands): providers here suggest plain text SUFFIX candidates to
467
+ * append at the cursor — the first renders as dim ghost text, up/down
468
+ * rotates through the rest, Tab accepts. No popover.
469
+ *
470
+ * Providers are queried in registration order with a shared AbortSignal;
471
+ * the engine returns the first provider's non-empty candidate list.
472
+ * Extensions add providers via `ctx.registerInlineAutocompleteProvider`
473
+ * — the built-in path provider ({@link createPathAutocompleteProvider})
474
+ * is always first.
475
+ */
476
+ /** Default per-provider deadline — a hung provider is treated as "no suggestion". */
477
+ const PROVIDER_DEADLINE_MS = 150;
478
+ /** Hard cap on a provider's `deadlineMs` — past this, waiting for a ghost reads as broken. */
479
+ const MAX_PROVIDER_DEADLINE_MS = 2e3;
480
+ /**
481
+ * Query `providers` in order; the first provider yielding a non-empty
482
+ * candidate list wins (a bare string normalizes to a 1-element list).
483
+ * Provider errors are swallowed (a broken extension provider must not
484
+ * break typing), and each provider races its deadline —
485
+ * {@link PROVIDER_DEADLINE_MS} by default, a provider's own `deadlineMs`
486
+ * (capped at {@link MAX_PROVIDER_DEADLINE_MS}) when declared — so one
487
+ * hung provider can't permanently suppress the ghosts of providers
488
+ * behind it. Returns null when aborted or nothing matched.
489
+ */
490
+ async function resolveInlineSuggestion(providers, ctx, deadlineMs = PROVIDER_DEADLINE_MS, maxDeadlineMs = MAX_PROVIDER_DEADLINE_MS) {
491
+ const TIMEOUT = Symbol("timeout");
492
+ for (const provider of providers) {
493
+ if (ctx.signal.aborted) return null;
494
+ try {
495
+ let timer;
496
+ const budget = provider.deadlineMs !== void 0 ? Math.min(Math.max(provider.deadlineMs, 1), maxDeadlineMs) : deadlineMs;
497
+ const result = await Promise.race([Promise.resolve(provider.suggest(ctx)), new Promise((r) => {
498
+ timer = setTimeout(r, budget, TIMEOUT);
499
+ })]);
500
+ if (timer !== void 0) clearTimeout(timer);
501
+ if (result === TIMEOUT) continue;
502
+ if (ctx.signal.aborted) return null;
503
+ const suffixes = (typeof result === "string" ? [result] : result ?? []).filter((s) => typeof s === "string" && s.length > 0);
504
+ if (suffixes.length > 0) return {
505
+ suffixes,
506
+ providerId: provider.id
507
+ };
508
+ } catch {}
509
+ }
510
+ return null;
511
+ }
512
+ /**
513
+ * Extract the whitespace-delimited token that ends at `cursor`. Returns
514
+ * null when the cursor sits right after whitespace (nothing to complete).
515
+ */
516
+ function tokenEndingAtCursor(text, cursor) {
517
+ const match = text.slice(0, cursor).match(/\S+$/);
518
+ return match ? match[0] : null;
519
+ }
520
+ /**
521
+ * Does this token carry an explicit path shape (`/` separator or a `~`
522
+ * prefix)? Path-shaped tokens complete from the first character after
523
+ * the separator; bare tokens (`sr` → `c/`) still complete against the
524
+ * cwd listing but need ≥2 chars first — see the provider.
525
+ */
526
+ function looksLikePath(token) {
527
+ return token.includes("/") || token.startsWith("~");
528
+ }
529
+ /**
530
+ * Cap on candidates returned for an ambiguous fragment — rotation past
531
+ * a dozen-odd entries stops being faster than typing another character.
532
+ */
533
+ const MAX_PATH_CANDIDATES = 16;
534
+ function defaultReaddir(dir) {
535
+ return readdirSync(dir, { withFileTypes: true }).map((e) => ({
536
+ name: e.name,
537
+ isDirectory: e.isDirectory()
538
+ }));
539
+ }
540
+ /**
541
+ * Built-in provider: complete filesystem paths under `cwd` (or absolute /
542
+ * `~` paths). Given the token `src/chat/comp`, lists `src/chat/` and
543
+ * returns every matching continuation of `comp` as a candidate list —
544
+ * directories first, alphabetical, `/`-terminated for directories so
545
+ * typing can continue. The host ghosts the first candidate and rotates
546
+ * through the rest on up/down.
547
+ *
548
+ * Bare tokens (no `/` yet) are matched against the cwd listing too, so
549
+ * a directory ghost appears as soon as the first characters of `src`
550
+ * are typed — but only from the second character on, so 1-letter prose
551
+ * words don't ghost-spam.
552
+ *
553
+ * Dotfiles are only offered when the typed fragment itself starts with
554
+ * `.` — the usual completion convention.
555
+ */
556
+ function createPathAutocompleteProvider(opts = {}) {
557
+ const readdir = opts.io?.readdir ?? defaultReaddir;
558
+ const fallbackCwd = opts.cwd ?? (() => process.cwd());
559
+ return {
560
+ id: "paths",
561
+ suggest(ctx) {
562
+ const token = tokenEndingAtCursor(ctx.text, ctx.cursor);
563
+ if (!token) return null;
564
+ if (!looksLikePath(token) && token.length < 2) return null;
565
+ const expanded = token.startsWith("~") ? join(homedir(), token.slice(1)) : token;
566
+ const endsWithSep = expanded.endsWith("/");
567
+ const dirPart = endsWithSep ? expanded : dirname(expanded);
568
+ const fragment = endsWithSep ? "" : basename(expanded);
569
+ const baseCwd = ctx.cwd || fallbackCwd();
570
+ const dir = isAbsolute(dirPart) ? dirPart : resolve(baseCwd, dirPart);
571
+ let entries;
572
+ try {
573
+ entries = readdir(dir);
574
+ } catch {
575
+ return null;
576
+ }
577
+ const matches = entries.filter((e) => e.name.startsWith(fragment) && (fragment.startsWith(".") || !e.name.startsWith(".")));
578
+ matches.sort((a, b) => a.isDirectory !== b.isDirectory ? a.isDirectory ? -1 : 1 : a.name.localeCompare(b.name));
579
+ const suffixes = matches.map((e) => e.name.slice(fragment.length) + (e.isDirectory ? "/" : "")).filter((s) => s.length > 0).slice(0, MAX_PATH_CANDIDATES);
580
+ return suffixes.length > 0 ? suffixes : null;
581
+ }
582
+ };
583
+ }
584
+ /**
585
+ * Optimistically transform the current ghost across a buffer edit so it
586
+ * stays visible while the debounced provider re-query is in flight —
587
+ * without this, every keystroke inside a completable token blinks the
588
+ * ghost (hide → 120 ms → reshow).
589
+ *
590
+ * Two edits are reconcilable (everything else returns null and waits for
591
+ * the authoritative result):
592
+ * - typing FORWARD along the suggestion (`bi` → `bin` with ghost
593
+ * `n/`): candidates are filtered to those starting with the typed
594
+ * chars and trimmed;
595
+ * - deleting BACKWARD within the token (`bin` → `bi` with ghost `/`):
596
+ * the deleted chars are re-prepended to every candidate. Guarded to
597
+ * non-whitespace so crossing a token boundary drops the ghost.
598
+ *
599
+ * The patched ghost is provisional — the provider's answer replaces it
600
+ * (and may add candidates the patch couldn't know about).
601
+ */
602
+ function patchInlineGhost(prev, text, cursor) {
603
+ if (!prev) return null;
604
+ if (text === prev.text && cursor === prev.cursor) return prev;
605
+ if (text.slice(cursor) !== prev.text.slice(prev.cursor)) return null;
606
+ if (cursor > prev.cursor) {
607
+ if (text.slice(0, prev.cursor) !== prev.text.slice(0, prev.cursor)) return null;
608
+ const typed = text.slice(prev.cursor, cursor);
609
+ const shown = prev.suffixes[prev.index];
610
+ const suffixes = prev.suffixes.filter((s) => s.startsWith(typed) && s.length > typed.length).map((s) => s.slice(typed.length));
611
+ if (suffixes.length === 0) return null;
612
+ const shownTrimmed = shown.startsWith(typed) ? shown.slice(typed.length) : null;
613
+ return {
614
+ suffixes,
615
+ index: shownTrimmed !== null ? Math.max(0, suffixes.indexOf(shownTrimmed)) : 0,
616
+ text,
617
+ cursor
618
+ };
619
+ }
620
+ if (prev.text.slice(0, cursor) !== text.slice(0, cursor)) return null;
621
+ const deleted = prev.text.slice(cursor, prev.cursor);
622
+ if (deleted.length === 0 || /\s/.test(deleted)) return null;
623
+ return {
624
+ suffixes: prev.suffixes.map((s) => deleted + s),
625
+ index: prev.index,
626
+ text,
627
+ cursor
628
+ };
629
+ }
630
+ //#endregion
631
+ //#region src/extension-authoring.ts
632
+ /**
633
+ * Session-metadata key marking an "edit / create extension" session. The value
634
+ * is an {@link ExtensionEditMarker}. Both hosts stamp it at session creation so
635
+ * the agent build can scope the docs skill to exactly these sessions (and it
636
+ * survives reload because metadata is persisted).
637
+ */
638
+ const EXTENSION_EDIT_METADATA_KEY = "zidane.extensionEdit";
639
+ /** Name of the inline docs skill auto-activated in extension edit sessions. */
640
+ const ZIDANE_EXTENSION_SKILL_NAME = "zidane-extension";
641
+ /**
642
+ * Session-metadata key marking an "edit skill" session — the skill-editing
643
+ * counterpart of {@link EXTENSION_EDIT_METADATA_KEY}. The value is a
644
+ * {@link SkillEditMarker}. Stamped at session creation by both hosts (via
645
+ * {@link editSessionMetadata}); persisted metadata means it survives reload.
646
+ */
647
+ const SKILL_EDIT_METADATA_KEY = "zidane.skillEdit";
648
+ /** Name of the inline preset skill auto-activated in skill edit sessions. */
649
+ const ZIDANE_SKILL_EDIT_SKILL_NAME = "zidane-skill";
650
+ /**
651
+ * THE canonical seed for "open an editing session with preset context" —
652
+ * the session metadata both hosts stamp at creation time. The engines read
653
+ * the marker back and inject + auto-activate the matching inline preset
654
+ * skill ({@link buildZidaneExtensionSkill} / {@link buildZidaneSkillEditSkill}),
655
+ * so the preset rides as real conversation context from turn one.
656
+ */
657
+ function editSessionMetadata(target) {
658
+ return target.kind === "extension" ? { [EXTENSION_EDIT_METADATA_KEY]: { dir: target.dir } } : { [SKILL_EDIT_METADATA_KEY]: {
659
+ dir: target.dir,
660
+ name: target.name
661
+ } };
662
+ }
663
+ /**
664
+ * Parse a persisted {@link SkillEditMarker} back out of session metadata.
665
+ * Tolerant: anything that isn't `{ dir: string, name: string }` → `null`
666
+ * (older sessions, manual metadata edits, type drift).
667
+ */
668
+ function readSkillEditMarker(raw) {
669
+ if (typeof raw !== "object" || raw === null) return null;
670
+ const { dir, name } = raw;
671
+ if (typeof dir !== "string" || dir.length === 0 || typeof name !== "string" || name.length === 0) return null;
672
+ return {
673
+ dir,
674
+ name
675
+ };
676
+ }
677
+ /**
678
+ * Minimal valid extension module for a freshly-created extension. `name` MUST
679
+ * equal the folder name or the loader rejects it (see `src/extensions/discovery`).
680
+ * Does nothing by default — the author registers contributions via `ctx.*`
681
+ * inside `setup`; the auto-activated docs skill guides them from there.
682
+ */
683
+ function extensionScaffold(name) {
684
+ return `import { defineExtension } from 'zidane'
685
+
686
+ /**
687
+ * ${name} — a zidane extension.
688
+ *
689
+ * Register tools / hooks / skills / MCP servers / subagents / config / themes /
690
+ * agents / keybindings via the \`ctx\` surface inside \`setup\`.
691
+ *
692
+ * Authoring reference: activate the \`${ZIDANE_EXTENSION_SKILL_NAME}\` skill — it carries
693
+ * the full \`ctx\` API + invariants and is available whenever you open this
694
+ * folder in zidane (or run \`zidane\` from inside it).
695
+ */
696
+ export default defineExtension({
697
+ name: '${name}',
698
+ setup(ctx) {
699
+ // e.g. ctx.registerTool('${name}_hello', {
700
+ // spec: { name: '${name}_hello', description: 'say hi', inputSchema: { type: 'object', properties: {} } },
701
+ // isConcurrencySafe: true,
702
+ // async execute() { return 'hello from ${name}' },
703
+ // })
704
+ },
705
+ })
706
+ `;
707
+ }
708
+ /**
709
+ * Locate the shipped `docs/` directory (`docs/EXTENSIONS.md` + siblings).
710
+ *
711
+ * The docs ship via `package.json` `files`, so an installed consumer has
712
+ * `<root>/node_modules/zidane/docs` and the repo itself has `<repo>/docs`.
713
+ * Returns the first existing candidate, or `null` (the skill then falls back to
714
+ * the literal `node_modules/zidane/docs` hint, which the model's file tools can
715
+ * still attempt from cwd).
716
+ */
717
+ function resolveExtensionDocsDir(cwd = process.cwd()) {
718
+ const candidates = [];
719
+ try {
720
+ const req = createRequire(import.meta.url);
721
+ candidates.push(join(dirname(req.resolve("zidane/package.json")), "docs"));
722
+ } catch {}
723
+ try {
724
+ let dir = dirname(fileURLToPath(import.meta.url));
725
+ for (let i = 0; i < 6; i++) {
726
+ if (existsSync(join(dir, "docs", "EXTENSIONS.md"))) {
727
+ candidates.push(join(dir, "docs"));
728
+ break;
729
+ }
730
+ dir = dirname(dir);
731
+ }
732
+ } catch {}
733
+ candidates.push(join(cwd, "node_modules", "zidane", "docs"));
734
+ for (const candidate of candidates) if (existsSync(join(candidate, "EXTENSIONS.md"))) return candidate;
735
+ return null;
736
+ }
737
+ /** Entry-file probe order for an extension dir (mirrors the loader's discovery). */
738
+ const EXTENSION_ENTRY_FILES = [
739
+ "index.ts",
740
+ "index.tsx",
741
+ "index.mjs",
742
+ "index.js"
743
+ ];
744
+ /**
745
+ * If `cwd` sits inside a zidane extension, return that extension's root dir,
746
+ * else `null`. Mirrors the discovery layout
747
+ * `<scope>/.{agents,zidane}/extensions/<name>/` — the root is the `<name>` dir
748
+ * (parent `extensions`, grandparent `.agents` / `.zidane`) holding an entry
749
+ * file. Walks up from `cwd`, so the nearest enclosing extension wins. Lets the
750
+ * hosts surface the authoring skill for a plain `zidane` launched from an
751
+ * extension folder, not just sessions opened through the edit/create flow.
752
+ */
753
+ function extensionDirForCwd(cwd = process.cwd()) {
754
+ let dir = resolve(cwd);
755
+ for (let i = 0; i < 64; i++) {
756
+ const parent = dirname(dir);
757
+ if (basename(parent) === "extensions" && (basename(dirname(parent)) === ".agents" || basename(dirname(parent)) === ".zidane") && EXTENSION_ENTRY_FILES.some((f) => existsSync(join(dir, f)))) return dir;
758
+ if (parent === dir) break;
759
+ dir = parent;
760
+ }
761
+ return null;
762
+ }
763
+ /**
764
+ * The authoring reference embedded into the skill body. Kept INLINE (not a
765
+ * filesystem path) on purpose: a `bun build --compile` TUI binary has no
766
+ * resolvable `node_modules/zidane/docs`, no real `import.meta.url`, and the
767
+ * edited extension's own cwd has no `node_modules` — so a path reference dead-
768
+ * ends there. The model gets the API verbatim on activation regardless of host.
769
+ *
770
+ * Mirrors `ExtensionContext` (src/extensions/types.ts) — keep in sync when the
771
+ * `ctx.*` surface changes.
772
+ */
773
+ const EXTENSION_AUTHORING_CHEATSHEET = `## defineExtension
774
+
775
+ A zidane extension is a module at \`<scope>/.{agents,zidane}/extensions/<name>/index.{ts,tsx,mjs,js}\`
776
+ that default-exports \`defineExtension({ ... })\`. It runs in-process in the host (TUI / GUI / SDK).
777
+
778
+ \`\`\`ts
779
+ import { defineExtension } from 'zidane'
780
+
781
+ export default defineExtension({
782
+ name: 'my-ext', // MUST equal the folder name
783
+ apiVersion: 1, // optional; omitted means v1
784
+ version: '0.1.0', // optional — shown in Settings → Extensions
785
+ displayName: 'My Ext', // optional UI label
786
+ description: 'what it does',
787
+ capabilities: ['tools'], // optional descriptive manifest labels
788
+ permissions: [], // optional descriptive labels; not a sandbox
789
+ setup(ctx) { /* register everything here; may be async */ },
790
+ teardown() {}, // optional, reserved (no hot-reload yet)
791
+ })
792
+ \`\`\`
793
+
794
+ Zero-import form — required INSIDE compiled binaries, which can't \`import 'zidane'\` at runtime:
795
+ \`\`\`ts
796
+ import type { ExtensionContext } from 'zidane' // type-only: erased at runtime
797
+ export default {
798
+ name: 'my-ext',
799
+ setup(ctx: ExtensionContext) { /* ... */ },
800
+ }
801
+ \`\`\`
802
+
803
+ ## ctx surface (call everything inside setup)
804
+
805
+ Identity / utilities:
806
+ - ctx.name / ctx.version / ctx.dir / ctx.scope ('project' | 'user' | 'inline' | 'builtin')
807
+ - ctx.logger — Logger namespaced \`extension:<name>\`
808
+ - ctx.config — read THIS extension's config, default-first: \`ctx.config.get(key)\`
809
+ - ctx.paths — cwd/projectRoot/dataDir/cacheDir/configDir/stateDir
810
+ - ctx.storage.path(scope, ...segments) — extension-scoped storage path
811
+ - ctx.session — JSON-only per-session state: get / set / delete
812
+
813
+ Renderer-agnostic registration:
814
+ - ctx.registerTool(name, toolDef)
815
+ - ctx.registerHook(event, handler) — an AgentHooks event; fires on the PARENT agent (use child:* for subagents)
816
+ - ctx.registerMcpServer(config)
817
+ - ctx.registerSkill(skillConfig) — a defineSkill() shape
818
+ - ctx.registerCompletionProvider(provider) — single-char '/' '@' '!' trigger
819
+ - ctx.tui.notify(text, { tone? }) — one-shot transcript line (info/warn/error); buffered pre-mount
820
+ - ctx.tui.setStatus(text | null, { loader?, timeout? }) — single dim status line (transcript-tail slot); loader glyph, auto-clear ms
821
+ - ctx.tui.setBanner(lines | null, { timeout? }) — short block above the prompt (stacked with the todo indicator); auto-clear ms
822
+ - ctx.submitPrompt(text, { delivery? }) — submit as the user: 'auto' (run-or-queue), 'steer', 'followUp'; false when detached
823
+ - ctx.registerToolPolicy({ tools?, decide }) — gate tool calls: allow / { block } / { patch } (patches re-validated by the loop)
824
+ - ctx.registerToolResultTransform(fn) — middleware over finished tool results (chained)
825
+ - ctx.registerContextTransform(fn) — rewrite wire messages before each provider call
826
+ - ctx.registerPromptTransform(fn) — transform or consume submitted prompts (after / commands, before skills)
827
+ - ctx.runtime / ctx.sessions — late-bound host controls: model()/thinking()/setThinking(), sessions list/switch/create
828
+ - ctx.appendNote(blocks) — durable display-only transcript note (command-output block vocabulary)
829
+ - ctx.workspace.exec/readFile/writeFile/listFiles/fileSize — shell + files through the SESSION'S execution context (sandbox-correct; host child_process silently targets the wrong filesystem under docker/e2b). Detached ⇒ rejects; guard with \`await ctx.workspace.available()\`.
830
+ - ctx.tui.registerToolRenderer({ tools?, render }) — custom view for tool-result rows; return null to fall through to the default. View-layer only: never persisted, error-bounded per row, TUI-scoped.
831
+ - ctx.tui.registerRenderer(id, render) + emit/appendNote \`custom: { renderer, data }\` blocks — custom components in the transcript. \`data\` must be JSON-safe (it persists); the block's \`text\` is the MANDATORY markdown fallback (disabled extension, headless host, GUI, renderer throw). appendNote auto-prefixes bare ids; command emit needs \`<ext>/<id>\`.
832
+ - Renderer components: import \`react\` / \`@opentui/react\` BARE (never as your own dependency) — the host maps them to its live instances at load time, so hooks work. Async data: render() stays sync; return a component that fetches with useState/useEffect.
833
+ - Renderer async caching: \`useSessionState(id, initial)\` (per-row, session-persisted — rehydrates on reload) + \`useRowVisible()\` (latched viewport gate) from \`zidane/tui\` — fetch once, only when scrolled in.
834
+ - ctx.registerCommand(command) — a '/<name>' workflow (handler or steps[]); step ctx: emit (local output),
835
+ runAgent (interactive sub-run), generateData (silent zod/JSON-Schema JSON),
836
+ session? (read-only turns/runs view), interaction? (confirm / askUser forms /
837
+ presentPlan approval — both optional; guard for bare hosts)
838
+ - ctx.registerTheme(theme)
839
+ - ctx.registerAgent(profile) — extra agent profile in the picker
840
+ - ctx.registerSubagent(def) — spawnable subagent (the spawn tool's subagent_type)
841
+ - ctx.registerSubagentOverride(name, fn) — edit/delete an existing subagent
842
+ - ctx.registerProvider(descriptor)
843
+ - ctx.registerSystemPromptFragment(text)
844
+ - ctx.registerContextBreakdownSection(fn) — render extension-owned rows in the context panel
845
+ - ctx.registerContextPanelSection(section) — richer context panel section with rows/load
846
+ - ctx.registerToolResultMetadata(registration) — project namespaced tool-result metadata into context
847
+ - ctx.registerRunTransform(fn) — sync rewrite of agent.run options
848
+ - ctx.registerBehaviorTransform(fn) — sync rewrite of resolved behavior
849
+ - ctx.registerProviderMiddleware(fn) — wrap provider instances
850
+ - ctx.registerToolCatalogTransform(fn) — shape/hide/rename tools before disclosure/execution
851
+ - ctx.registerMcpConfigTransform(fn) — rewrite MCP server configs before connect
852
+ - ctx.registerSkillPolicy(policy) — auto-activate skills by run context
853
+ - ctx.registerConfigCategory(category)
854
+ - ctx.registerConfigItem([categoryId,] item) — typed setting; supports scope/onChange metadata
855
+
856
+ TUI-only (ctx.tui.* — silently ignored on non-TUI hosts):
857
+ - ctx.tui.registerKeybinding(binding)
858
+ - ctx.tui.registerFooterHint(hint)
859
+ - ctx.tui.registerModal(modal) — keep UI lazy: \`node: () => import('./panel')\`
860
+
861
+ GUI-only:
862
+ - ctx.gui.registerPanel(panel) — collected, not rendered yet
863
+
864
+ Completion providers may also implement submit hooks:
865
+ - resolveReferences(refs, ctx, signal)
866
+ - onSubmit({ text, cursor, references }, signal)
867
+ Both TUI and GUI call these before dispatching a submitted prompt.
868
+
869
+ ## ToolDef shape
870
+ \`\`\`ts
871
+ ctx.registerTool('my_tool', {
872
+ spec: { name: 'my_tool', description: '...', inputSchema: { type: 'object', properties: {} } },
873
+ isConcurrencySafe: true, // default false (barrier/write); true = read-only, fans out in parallel
874
+ async execute(args) { return 'result' },
875
+ })
876
+ \`\`\`
877
+
878
+ ## Invariants
879
+ - The folder name MUST equal \`name\`, or the loader rejects the extension.
880
+ - Optional metadata lives in \`extension.json\` or \`package.json#zidane\`; unsupported \`apiVersion\` is rejected.
881
+ - Export as \`default\` (or a named \`extension\` export).
882
+ - Register ONLY inside \`setup\` — calls after it resolves are dropped (logger.warn).
883
+ - A throw in \`setup\` is caught and shown on the extension's Settings row; the rest of boot continues.
884
+ - Discovery order, first per name wins: project \`.agents\` → project \`.zidane\` → \`~/.agents\` → \`~/.zidane\`.
885
+ - \`/reload\` is a TRUE hot-reload: re-discover + re-run setup, and each extension's module graph is re-imported fresh — code edits (deep imports included) apply live. Module-level state resets; clean up external resources in \`teardown\`.
886
+
887
+ ## Distribution
888
+ - Users install via Settings → Extensions → \`ctrl+g\` (source input + scope picker) — GitHub (\`owner/repo[#ref][:sub/dir]\`, tree URLs) or npm (\`pkg[@version]\`, exact versions / dist-tags).
889
+ - Update / remove: Settings → Extensions, \`ctrl+u\` / \`ctrl+x\` on the focused row (update replays the recorded \`.install.json\` provenance in place, skipping when the recorded commit/version still matches the remote).
890
+ - Shape your repo/package as a root \`index.ts\` (single extension) or \`extensions/<name>/index.*\` dirs (collection).
891
+ - Reference packages to copy: \`extensions/git-flow\` in the zidane repo (zero-dep \`/commit\` command) and \`extensions/headroom\` (hooks + tool + config).
892
+ - Declare runtime deps in \`package.json#dependencies\` — installed with bun/npm/… WITHOUT lifecycle scripts; don't rely on postinstall.
893
+ - The installed folder is named from your exported \`definition.name\` — pick it to be folder-safe ([A-Za-z0-9_.-]).`;
894
+ /** Preset preamble for "edit / create extension" sessions. */
895
+ const EXTENSION_EDIT_PRESET = "You are authoring / editing a zidane extension. Authoritative API reference:";
896
+ /** Preset preamble for "edit skill" sessions — same spirit, skill-flavored. */
897
+ function skillEditPreset(marker) {
898
+ return `We are going to work on ${marker.name}, in ${marker.dir} directory, next messages will be requests at modifying and improving the skill or understanding how it works better.`;
899
+ }
900
+ /**
901
+ * The inline `zidane-extension` skill. Carries the authoring reference IN its
902
+ * body ({@link EXTENSION_AUTHORING_CHEATSHEET}) so it works in every host —
903
+ * incl. compiled binaries where the on-disk docs aren't resolvable. The shipped
904
+ * `EXTENSIONS.md` (prose long-form) is appended as a "see also" only when found
905
+ * on disk. `source: 'inline'` (the `defineSkill` default) survives the
906
+ * project-trust gate.
907
+ */
908
+ function buildZidaneExtensionSkill(opts = {}) {
909
+ const docsDir = resolveExtensionDocsDir(opts.cwd);
910
+ const seeAlso = docsDir ? `\n\n---\nFull prose reference on disk: ${join(docsDir, "EXTENSIONS.md")}\nSibling docs cover related surfaces (ARCHITECTURE.md, CHAT.md, TUI.md, SKILL.md, HEADLESS.md, …).` : "\n\n---\nFuller prose reference, when present: node_modules/zidane/docs/EXTENSIONS.md (often absent in compiled binaries — the API above is authoritative).";
911
+ return defineSkill({
912
+ name: ZIDANE_EXTENSION_SKILL_NAME,
913
+ description: "Authoring or editing a zidane extension (a module calling defineExtension, under .zidane/extensions/** or .agents/extensions/**). Carries the extension authoring reference.",
914
+ instructions: `${EXTENSION_EDIT_PRESET}\n\n${EXTENSION_AUTHORING_CHEATSHEET}${seeAlso}`
915
+ });
916
+ }
917
+ /**
918
+ * Inject the `zidane-extension` skill into an already-merged {@link SkillsConfig}.
919
+ *
920
+ * Call this on the FINAL merged skills object (after folding extension / preset
921
+ * skills): `composePresets` is last-wins on `catalog`, so injecting earlier
922
+ * would be silently dropped whenever an extension also contributes skills.
923
+ *
924
+ * Adds the skill to the catalog and, when an `enabled` allowlist is in force,
925
+ * force-includes the name so the allowlist filter can't hide it (mirrors how
926
+ * extension-contributed skill names are force-included). When skills are fully
927
+ * disabled (`enabled === false`), the edit session still gets just this skill.
928
+ */
929
+ function withZidaneExtensionSkill(skills, opts = {}) {
930
+ return injectInlineSkill(skills, buildZidaneExtensionSkill(opts));
931
+ }
932
+ /**
933
+ * Compact skill-authoring reference embedded into the skill-edit skill body.
934
+ * Inline for the same reason as {@link EXTENSION_AUTHORING_CHEATSHEET}: works
935
+ * in compiled binaries where on-disk docs don't resolve.
936
+ */
937
+ const SKILL_AUTHORING_CHEATSHEET = `## SKILL.md anatomy
938
+
939
+ A skill is a directory holding a \`SKILL.md\` (YAML frontmatter + markdown body)
940
+ per the Agent Skills standard (agentskills.io/specification):
941
+
942
+ \`\`\`md
943
+ ---
944
+ name: my-skill # 1-64 chars, lowercase alphanumeric + hyphens; SHOULD match the directory name
945
+ description: what it does AND when to use it (drives activation — 1-1024 chars)
946
+ license: MIT # optional
947
+ compatibility: … # optional environment requirements
948
+ allowed-tools: # optional — restricts the agent to these tools while active
949
+ - read_file
950
+ metadata: # optional flat string map; zidane-specific keys use the \`zidane.\` prefix
951
+ some-key: value
952
+ ---
953
+ The body is the skill's instructions — injected into context when the skill
954
+ is activated. Keep it token-lean: move depth into references/.
955
+ \`\`\`
956
+
957
+ ## Bundled resources (all optional, discovered automatically)
958
+ - \`references/\` — docs loaded on demand via \`skills_read\`
959
+ - \`scripts/\` — executables run via \`skills_run_script\` (honor shebangs)
960
+ - \`assets/\` — templates / static files
961
+
962
+ ## Guidance
963
+ - The description is the activation trigger — write it for the ROUTER ("use when …"), the body for the ACTIVATED agent.
964
+ - Prefer small bodies + lazy references over one giant SKILL.md.
965
+ - Shell interpolation (exclamation mark + backtick-quoted command) in the body runs at activation — use sparingly; hosts may gate or disable it.
966
+ - Validate names: mismatched frontmatter name vs directory loads with a warning diagnostic.`;
967
+ /**
968
+ * The inline skill auto-activated in "edit skill" sessions. Body = the
969
+ * session preset (which skill, which directory, what the next messages will
970
+ * be about) + a compact SKILL.md authoring reference — the skill-editing
971
+ * counterpart of {@link buildZidaneExtensionSkill}.
972
+ */
973
+ function buildZidaneSkillEditSkill(marker, opts = {}) {
974
+ const docsDir = resolveExtensionDocsDir(opts.cwd);
975
+ const seeAlso = docsDir && existsSync(join(docsDir, "SKILL.md")) ? `\n\n---\nFull prose reference on disk: ${join(docsDir, "SKILL.md")}` : "";
976
+ return defineSkill({
977
+ name: ZIDANE_SKILL_EDIT_SKILL_NAME,
978
+ description: `Editing or improving the "${marker.name}" agent skill (its SKILL.md, references, scripts, and assets). Carries the skill-authoring reference for this edit session.`,
979
+ instructions: `${skillEditPreset(marker)}\n\n${SKILL_AUTHORING_CHEATSHEET}${seeAlso}`
980
+ });
981
+ }
982
+ /**
983
+ * Inject the skill-edit preset skill into an already-merged {@link SkillsConfig}.
984
+ * Same contract as {@link withZidaneExtensionSkill} — call on the FINAL merged
985
+ * skills object.
986
+ */
987
+ function withZidaneSkillEditSkill(skills, marker, opts = {}) {
988
+ return injectInlineSkill(skills, buildZidaneSkillEditSkill(marker, opts));
989
+ }
990
+ /**
991
+ * Shared injector: add `skill` to the catalog and, when an `enabled`
992
+ * allowlist is in force, force-include its name so the filter can't hide it
993
+ * (mirrors how extension-contributed skill names are force-included). When
994
+ * skills are fully disabled (`enabled === false`), the edit session still
995
+ * gets just this skill.
996
+ */
997
+ function injectInlineSkill(skills, skill) {
998
+ const catalog = [...skills.catalog ?? [], skill];
999
+ const enabled = skills.enabled;
1000
+ const nextEnabled = enabled === void 0 ? void 0 : Array.isArray(enabled) ? enabled.includes(skill.name) ? enabled : [...enabled, skill.name] : [skill.name];
1001
+ return {
1002
+ ...skills,
1003
+ catalog,
1004
+ ...nextEnabled !== void 0 ? { enabled: nextEnabled } : {}
1005
+ };
1006
+ }
1007
+ //#endregion
1008
+ //#region src/mcp/oauth-callback.ts
1009
+ const DEFAULT_PATH = "/callback";
1010
+ const DEFAULT_HOST = "127.0.0.1";
1011
+ /**
1012
+ * Fallback renderer for hosts that don't inject one (bare SDK use) —
1013
+ * deliberately minimal, no framework, no branding. All interpolations are
1014
+ * escaped: a malicious authorization server could craft an
1015
+ * `error_description` containing markup, and even a one-shot loopback
1016
+ * page must not XSS.
1017
+ */
1018
+ const renderMinimalPage = (page) => {
1019
+ const escaped = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
1020
+ const ok = page.kind === "success";
1021
+ return `<!doctype html>
1022
+ <html><head><meta charset="utf-8"><title>${ok ? "Logged in" : "Login failed"}</title>
1023
+ <style>body{font:14px/1.5 -apple-system,system-ui,sans-serif;margin:6rem auto;max-width:28rem;text-align:center;color:#1d1d1f}.ok{color:#1f8a4c;font-weight:600}.err{color:#c43c2c;font-weight:600}</style>
1024
+ </head><body>
1025
+ <p class="${ok ? "ok" : "err"}">${ok ? "Logged in." : "Login failed."}</p>
1026
+ ${page.details ? `<p>${escaped(page.details)}</p>` : ""}
1027
+ <p>${escaped(page.message)}</p>
1028
+ </body></html>`;
1029
+ };
1030
+ const CLOSE_TAB_MESSAGE = "You can close this tab and return to the terminal.";
1031
+ /**
1032
+ * Start a one-shot OAuth callback server. The returned handle's `redirectUri`
1033
+ * should be passed to the authorization server as the `redirect_uri` query
1034
+ * parameter; `promise` resolves once the user finishes the browser flow.
1035
+ *
1036
+ * Always `await handle.close()` in a `finally` block — even on success, the
1037
+ * server stays open until told to shut down (so it can serve the
1038
+ * "you can close this tab" page).
1039
+ */
1040
+ async function startOAuthCallback(opts = {}) {
1041
+ const path = opts.path ?? DEFAULT_PATH;
1042
+ const host = opts.host ?? DEFAULT_HOST;
1043
+ const port = opts.port ?? 0;
1044
+ const renderPage = opts.renderPage ?? renderMinimalPage;
1045
+ const provider = opts.provider ?? "MCP server";
1046
+ if (!path.startsWith("/")) throw new Error(`OAuth callback path must start with "/" (got: ${JSON.stringify(path)})`);
1047
+ if (opts.signal?.aborted) throw new Error("OAuth callback aborted");
1048
+ let resolveResult;
1049
+ let rejectResult;
1050
+ const promise = new Promise((resolve, reject) => {
1051
+ resolveResult = resolve;
1052
+ rejectResult = reject;
1053
+ });
1054
+ promise.catch(() => {});
1055
+ let settled = false;
1056
+ const resolveOnce = (value) => {
1057
+ if (settled) return;
1058
+ settled = true;
1059
+ resolveResult(value);
1060
+ };
1061
+ const rejectOnce = (error) => {
1062
+ if (settled) return;
1063
+ settled = true;
1064
+ rejectResult(error);
1065
+ };
1066
+ const server = createServer((req, res) => {
1067
+ const url = new URL(req.url ?? "/", `http://${host}`);
1068
+ if (url.pathname !== path) {
1069
+ res.writeHead(404, { "content-type": "text/plain" });
1070
+ res.end("Not Found");
1071
+ return;
1072
+ }
1073
+ const htmlHeaders = {
1074
+ "content-type": "text/html; charset=utf-8",
1075
+ "cache-control": "no-store"
1076
+ };
1077
+ const error = url.searchParams.get("error");
1078
+ if (error) {
1079
+ const desc = url.searchParams.get("error_description") ?? error;
1080
+ res.writeHead(400, htmlHeaders);
1081
+ res.end(renderPage({
1082
+ kind: "error",
1083
+ message: CLOSE_TAB_MESSAGE,
1084
+ details: desc,
1085
+ provider
1086
+ }));
1087
+ rejectOnce(/* @__PURE__ */ new Error(`OAuth authorization failed: ${desc}`));
1088
+ return;
1089
+ }
1090
+ const code = url.searchParams.get("code");
1091
+ if (!code) {
1092
+ res.writeHead(400, { "content-type": "text/plain" });
1093
+ res.end("Missing \"code\" query parameter.");
1094
+ return;
1095
+ }
1096
+ const state = url.searchParams.get("state") ?? void 0;
1097
+ res.writeHead(200, htmlHeaders);
1098
+ res.end(renderPage({
1099
+ kind: "success",
1100
+ message: CLOSE_TAB_MESSAGE,
1101
+ provider
1102
+ }));
1103
+ resolveOnce({
1104
+ code,
1105
+ state
1106
+ });
1107
+ });
1108
+ server.on("error", (err) => {
1109
+ rejectOnce(err instanceof Error ? err : new Error(String(err)));
1110
+ });
1111
+ await new Promise((resolve, reject) => {
1112
+ server.once("error", reject);
1113
+ server.listen(port, host, () => {
1114
+ server.off("error", reject);
1115
+ resolve();
1116
+ });
1117
+ });
1118
+ const addr = server.address();
1119
+ if (!addr || typeof addr === "string") {
1120
+ server.close();
1121
+ throw new Error("OAuth callback server did not bind to a TCP port");
1122
+ }
1123
+ let closing;
1124
+ let closeServer;
1125
+ const onAbort = () => {
1126
+ rejectOnce(/* @__PURE__ */ new Error("OAuth callback aborted"));
1127
+ closeServer();
1128
+ };
1129
+ closeServer = () => {
1130
+ if (closing) return closing;
1131
+ closing = new Promise((resolve) => {
1132
+ server.closeAllConnections?.();
1133
+ server.close(() => {
1134
+ opts.signal?.removeEventListener("abort", onAbort);
1135
+ if (opts.signal?.aborted) rejectOnce(/* @__PURE__ */ new Error("OAuth callback aborted"));
1136
+ else rejectOnce(/* @__PURE__ */ new Error("OAuth callback server closed"));
1137
+ resolve();
1138
+ });
1139
+ });
1140
+ return closing;
1141
+ };
1142
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
1143
+ return {
1144
+ redirectUri: `http://${host}:${addr.port}${path}`,
1145
+ promise,
1146
+ close: closeServer
1147
+ };
1148
+ }
1149
+ //#endregion
1150
+ //#region src/mcp/login.ts
1151
+ const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 6e4;
1152
+ const DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
1153
+ /**
1154
+ * Run the full interactive OAuth flow for `config`. Only supports `sse` and
1155
+ * `streamable-http` transports — `stdio` MCP servers don't speak OAuth.
1156
+ *
1157
+ * Throws on:
1158
+ * - Wrong transport.
1159
+ * - Abort signal.
1160
+ * - Browser-side error (user denied, server rejected, etc.).
1161
+ * - Code exchange failure.
1162
+ * - Post-exchange connect failure.
1163
+ *
1164
+ * Always closes the loopback callback server before returning, success or
1165
+ * failure.
1166
+ */
1167
+ async function loginMcpServer(config, options) {
1168
+ if (config.transport !== "sse" && config.transport !== "streamable-http") throw new Error(`MCP OAuth: cannot login for transport "${config.transport}" — only sse / streamable-http are supported`);
1169
+ if (!config.url) throw new Error(`MCP OAuth: server "${config.name}" is missing a url`);
1170
+ const hooks = options.hooks;
1171
+ let settled = false;
1172
+ let callback;
1173
+ try {
1174
+ callback = await startOAuthCallback({
1175
+ signal: options.signal,
1176
+ path: options.callbackPath,
1177
+ ...options.renderCallbackPage ? { renderPage: options.renderCallbackPage } : {},
1178
+ provider: config.name
1179
+ });
1180
+ const handle = callback;
1181
+ const provider = new McpOAuthProvider({
1182
+ name: config.name,
1183
+ store: options.store,
1184
+ redirectUri: handle.redirectUri,
1185
+ clientName: options.clientName,
1186
+ scope: options.scope,
1187
+ onAuthorizationUrl: async (url) => {
1188
+ if (settled || options.signal?.aborted) return;
1189
+ await hooks?.callHook("mcp:auth:url", {
1190
+ name: config.name,
1191
+ url: url.toString()
1192
+ });
1193
+ await options.onAuthorizationUrl?.(url);
1194
+ }
1195
+ });
1196
+ let transport = await createInteractiveTransport(config, provider);
1197
+ const client = await createTolerantClient({
1198
+ name: "zidane",
1199
+ version: "1.0.0"
1200
+ });
1201
+ let needsAuth = false;
1202
+ const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
1203
+ for (let attempt = 0;; attempt++) try {
1204
+ await raceAbort(client.connect(transport), options.signal, connectTimeoutMs, `MCP OAuth: "${config.name}" did not respond within ${connectTimeoutMs}ms — the server may be unreachable`);
1205
+ break;
1206
+ } catch (err) {
1207
+ if (isUnauthorizedError(err)) {
1208
+ needsAuth = true;
1209
+ break;
1210
+ }
1211
+ if (attempt === 0 && isOAuthTokenRejectionError(err)) {
1212
+ await provider.invalidateCredentials("tokens");
1213
+ await transport.close().catch(() => {});
1214
+ transport = await createInteractiveTransport(config, provider);
1215
+ continue;
1216
+ }
1217
+ await client.close().catch(() => {});
1218
+ throw err;
1219
+ }
1220
+ if (needsAuth) {
1221
+ const { code } = await raceLoginCallback(handle, options.timeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS, options.signal);
1222
+ await transport.finishAuth(code);
1223
+ await transport.close().catch(() => {});
1224
+ const freshTransport = await createInteractiveTransport(config, provider);
1225
+ await raceAbort(client.connect(freshTransport), options.signal, connectTimeoutMs, `MCP OAuth: "${config.name}" did not respond after the token exchange (within ${connectTimeoutMs}ms)`);
1226
+ }
1227
+ const { tools } = await raceAbort(client.listTools(), options.signal, connectTimeoutMs, `MCP OAuth: "${config.name}" did not return its tool list within ${connectTimeoutMs}ms`);
1228
+ await client.close();
1229
+ const tokens = provider.tokens();
1230
+ if (!tokens) throw new Error(`MCP OAuth: login for "${config.name}" returned no tokens (server may have rejected the exchange silently)`);
1231
+ await hooks?.callHook("mcp:auth:success", { name: config.name });
1232
+ return {
1233
+ tokens,
1234
+ tools
1235
+ };
1236
+ } catch (err) {
1237
+ const error = err instanceof Error ? err : new Error(String(err));
1238
+ await hooks?.callHook("mcp:auth:error", {
1239
+ name: config.name,
1240
+ error
1241
+ });
1242
+ throw error;
1243
+ } finally {
1244
+ settled = true;
1245
+ await callback?.close();
1246
+ }
1247
+ }
1248
+ /**
1249
+ * Build an `sse` / `streamable-http` transport pre-wired with `authProvider`.
1250
+ * Mirrors the bootstrap-side `createTransport` shape but inlined here so the
1251
+ * login flow doesn't depend on the bootstrap module's private export.
1252
+ */
1253
+ async function createInteractiveTransport(rawConfig, authProvider) {
1254
+ const config = expandMcpConfigEnv(rawConfig);
1255
+ if (config.transport === "sse") {
1256
+ const { SSEClientTransport } = await import("@modelcontextprotocol/sdk/client/sse.js");
1257
+ return new SSEClientTransport(new URL(config.url), {
1258
+ requestInit: config.headers ? { headers: config.headers } : void 0,
1259
+ authProvider
1260
+ });
1261
+ }
1262
+ const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
1263
+ return new StreamableHTTPClientTransport(new URL(config.url), {
1264
+ requestInit: config.headers ? { headers: config.headers } : void 0,
1265
+ fetch: sseToJsonFetchIfNeeded(),
1266
+ authProvider
1267
+ });
1268
+ }
1269
+ function isUnauthorizedError(err) {
1270
+ if (!err || typeof err !== "object") return false;
1271
+ const e = err;
1272
+ if (e.name === "UnauthorizedError" || e.constructor?.name === "UnauthorizedError") return true;
1273
+ return typeof e.message === "string" && e.message.toLowerCase().startsWith("unauthorized");
1274
+ }
1275
+ /**
1276
+ * Race `promise` against the external abort signal and a hard timeout.
1277
+ *
1278
+ * Used for phases we can't cancel from the inside (the SDK's `connect()`
1279
+ * accepts no signal). The underlying work isn't stopped — it's abandoned —
1280
+ * so callers must treat a rejection as "this attempt is over" and close
1281
+ * whatever they own. Rejects with `'OAuth login aborted'` on abort so the
1282
+ * host can tell a user cancel apart from a genuine failure.
1283
+ */
1284
+ async function raceAbort(promise, signal, timeoutMs, timeoutMessage) {
1285
+ if (signal?.aborted) throw new Error("OAuth login aborted");
1286
+ promise.catch(() => {});
1287
+ let timer;
1288
+ let onAbort;
1289
+ try {
1290
+ return await new Promise((resolve, reject) => {
1291
+ timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
1292
+ if (signal) {
1293
+ onAbort = () => reject(/* @__PURE__ */ new Error("OAuth login aborted"));
1294
+ signal.addEventListener("abort", onAbort, { once: true });
1295
+ }
1296
+ promise.then(resolve, reject);
1297
+ });
1298
+ } finally {
1299
+ if (timer) clearTimeout(timer);
1300
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
1301
+ }
1302
+ }
1303
+ /**
1304
+ * Wait on the callback handle, with a hard timeout AND honor the external
1305
+ * abort signal. Unlike `callback.promise` alone, this provides a deterministic
1306
+ * failure mode for "user opened the browser then walked away" — the modal
1307
+ * doesn't hang forever waiting for a redirect that may never come.
1308
+ */
1309
+ async function raceLoginCallback(handle, timeoutMs, signal) {
1310
+ if (signal?.aborted) throw new Error("OAuth login aborted");
1311
+ let timer;
1312
+ let onAbort;
1313
+ try {
1314
+ return await new Promise((resolve, reject) => {
1315
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`OAuth login timed out after ${timeoutMs}ms`)), timeoutMs);
1316
+ if (signal) {
1317
+ onAbort = () => reject(/* @__PURE__ */ new Error("OAuth login aborted"));
1318
+ signal.addEventListener("abort", onAbort, { once: true });
1319
+ }
1320
+ handle.promise.then(resolve, reject);
1321
+ });
1322
+ } finally {
1323
+ if (timer) clearTimeout(timer);
1324
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
1325
+ }
1326
+ }
1327
+ //#endregion
1328
+ //#region src/metrics.ts
1329
+ function prefixed$1(prefix, name) {
1330
+ return prefix ? `${prefix}${name}` : name;
1331
+ }
1332
+ /**
1333
+ * Drop `undefined` entries before handing attributes to a metrics
1334
+ * backend. OTel's `@opentelemetry/api` rejects `undefined` attribute
1335
+ * values (and Prometheus / StatsD adapters silently mis-label them); the
1336
+ * helper's own API surface allows `undefined` for ergonomic call sites
1337
+ * (`{...(cond ? { foo } : {})}`-style spreads), so we strip here at the
1338
+ * boundary.
1339
+ */
1340
+ function mergeAttrs(base, extra) {
1341
+ const out = {};
1342
+ if (base) {
1343
+ for (const [k, v] of Object.entries(base)) if (v !== void 0) out[k] = v;
1344
+ }
1345
+ for (const [k, v] of Object.entries(extra)) if (v !== void 0) out[k] = v;
1346
+ return out;
1347
+ }
1348
+ /**
1349
+ * Build a set of metrics hook handlers that can be installed on an agent.
1350
+ *
1351
+ * @example OpenTelemetry
1352
+ * ```ts
1353
+ * import { metrics } from '@opentelemetry/api'
1354
+ * const meter = metrics.getMeter('zidane')
1355
+ * const m = createMetricsHooks({ meter, baseAttributes: { service: 'tui' } })
1356
+ * const uninstall = m.install(agent.hooks)
1357
+ * try { await agent.run({ prompt }) }
1358
+ * finally { uninstall() }
1359
+ * ```
1360
+ */
1361
+ function createMetricsHooks(options) {
1362
+ const ns = options.namespace;
1363
+ const base = options.baseAttributes;
1364
+ const onError = options.onError ?? (() => {});
1365
+ const make = {
1366
+ counter: (name, opts) => safeFactory(() => options.meter.createCounter(prefixed$1(ns, name), opts), name, onError),
1367
+ histogram: (name, opts) => safeFactory(() => options.meter.createHistogram(prefixed$1(ns, name), opts), name, onError),
1368
+ upDown: (name, opts) => safeFactory(() => options.meter.createUpDownCounter(prefixed$1(ns, name), opts), name, onError)
1369
+ };
1370
+ const turnDuration = make.histogram("gen_ai.client.operation.duration", {
1371
+ unit: "ms",
1372
+ description: "Per-turn LLM operation duration."
1373
+ });
1374
+ const tokenUsage = make.histogram("gen_ai.client.token.usage", {
1375
+ unit: "tokens",
1376
+ description: "Per-turn token usage (tagged by gen_ai.token.type)."
1377
+ });
1378
+ const ttft = make.histogram("gen_ai.client.time_to_first_token", {
1379
+ unit: "ms",
1380
+ description: "Per-turn time to first token."
1381
+ });
1382
+ const toolDuration = make.histogram("gen_ai.tool.duration", {
1383
+ unit: "ms",
1384
+ description: "Native tool execution wall clock."
1385
+ });
1386
+ const toolOutput = make.histogram("gen_ai.tool.output_bytes", {
1387
+ unit: "By",
1388
+ description: "Native tool output bytes."
1389
+ });
1390
+ const mcpToolDuration = make.histogram("gen_ai.mcp.tool.duration", {
1391
+ unit: "ms",
1392
+ description: "MCP tool execution wall clock."
1393
+ });
1394
+ const mcpBootstrapDuration = make.histogram("gen_ai.mcp.bootstrap.duration", {
1395
+ unit: "ms",
1396
+ description: "MCP server bootstrap duration."
1397
+ });
1398
+ const runsStarted = make.counter("gen_ai.agent.runs", { description: "Runs started." });
1399
+ const runsCompleted = make.counter("gen_ai.agent.runs.completed", { description: "Runs completed." });
1400
+ const aborts = make.counter("gen_ai.agent.aborts", { description: "Run abort events." });
1401
+ const toolCalls = make.counter("gen_ai.tool.calls", { description: "Tool dispatches (tagged by outcome)." });
1402
+ const toolErrors = make.counter("gen_ai.tool.errors", { description: "Native tool errors." });
1403
+ const mcpToolErrors = make.counter("gen_ai.mcp.tool.errors", { description: "MCP tool errors." });
1404
+ const streamErrors = make.counter("gen_ai.stream.errors", { description: "Provider stream errors." });
1405
+ const validationRejects = make.counter("gen_ai.validation.rejects", { description: "Tool input validation rejects." });
1406
+ const validationCoercions = make.counter("gen_ai.validation.coercions", { description: "Tool input auto-coercions." });
1407
+ const gateBlocks = make.counter("gen_ai.gate.blocks", { description: "Tool-gate refusals." });
1408
+ const budgetExceeded = make.counter("gen_ai.budget.exceeded", { description: "Per-turn byte budget exceedances." });
1409
+ const toolBudgetExceeded = make.counter("gen_ai.tool_budget.exceeded", { description: "Per-tool dispatch budget exceedances." });
1410
+ const pairingRepairs = make.counter("gen_ai.pairing.repairs", { description: "Tool-pairing repair counts." });
1411
+ const oauthRefreshes = make.counter("gen_ai.oauth.refreshes", { description: "Provider OAuth credential refreshes." });
1412
+ const mcpErrors = make.counter("gen_ai.mcp.errors", { description: "MCP server errors." });
1413
+ const mcpAuthRequired = make.counter("gen_ai.mcp.auth.required", { description: "MCP bootstrap blocked on missing auth." });
1414
+ const costMeter = make.counter("gen_ai.cost_usd", {
1415
+ unit: "USD",
1416
+ description: "Cumulative cost across turns."
1417
+ });
1418
+ const runsActive = make.upDown("gen_ai.agent.runs.active", { description: "In-flight agent runs." });
1419
+ return { install(hooks) {
1420
+ const turnStart = /* @__PURE__ */ new Map();
1421
+ const toolStart = /* @__PURE__ */ new Map();
1422
+ const mcpStart = /* @__PURE__ */ new Map();
1423
+ const unregisters = [];
1424
+ const record = (instrument, value, attrs, kind) => {
1425
+ if (!instrument) return;
1426
+ try {
1427
+ instrument.record(value, mergeAttrs(base, attrs));
1428
+ } catch (err) {
1429
+ try {
1430
+ onError(kind, err);
1431
+ } catch {}
1432
+ }
1433
+ };
1434
+ const add = (instrument, value, attrs, kind) => {
1435
+ if (!instrument) return;
1436
+ try {
1437
+ instrument.add(value, mergeAttrs(base, attrs));
1438
+ } catch (err) {
1439
+ try {
1440
+ onError(kind, err);
1441
+ } catch {}
1442
+ }
1443
+ };
1444
+ unregisters.push(hooks.hook("agent:start", (ctx) => {
1445
+ const attrs = {
1446
+ "gen_ai.agent.depth": ctx.depth,
1447
+ ...ctx.agentName ? { "gen_ai.agent.name": ctx.agentName } : {}
1448
+ };
1449
+ add(runsStarted, 1, attrs, "runs");
1450
+ add(runsActive, 1, attrs, "runs.active");
1451
+ }));
1452
+ unregisters.push(hooks.hook("agent:done", () => {
1453
+ add(runsCompleted, 1, {}, "runs.completed");
1454
+ add(runsActive, -1, {}, "runs.active");
1455
+ turnStart.clear();
1456
+ toolStart.clear();
1457
+ mcpStart.clear();
1458
+ }));
1459
+ unregisters.push(hooks.hook("agent:abort", () => {
1460
+ add(aborts, 1, {}, "aborts");
1461
+ }));
1462
+ unregisters.push(hooks.hook("turn:before", (ctx) => {
1463
+ turnStart.set(ctx.turnId, Date.now());
1464
+ }));
1465
+ unregisters.push(hooks.hook("turn:after", (ctx) => {
1466
+ const started = turnStart.get(ctx.turnId);
1467
+ turnStart.delete(ctx.turnId);
1468
+ const tagsBase = {
1469
+ "gen_ai.operation.name": "chat",
1470
+ ...ctx.usage.modelId ? { "gen_ai.response.model": ctx.usage.modelId } : {},
1471
+ ...ctx.usage.finishReason ? { "gen_ai.response.finish_reason": ctx.usage.finishReason } : {}
1472
+ };
1473
+ if (typeof started === "number") record(turnDuration, Date.now() - started, tagsBase, "turn.duration");
1474
+ if (typeof ctx.usage.timeToFirstTokenMs === "number") record(ttft, ctx.usage.timeToFirstTokenMs, tagsBase, "ttft");
1475
+ record(tokenUsage, ctx.usage.input, {
1476
+ ...tagsBase,
1477
+ "gen_ai.token.type": "input"
1478
+ }, "token.input");
1479
+ record(tokenUsage, ctx.usage.output, {
1480
+ ...tagsBase,
1481
+ "gen_ai.token.type": "output"
1482
+ }, "token.output");
1483
+ if (typeof ctx.usage.cacheRead === "number" && ctx.usage.cacheRead > 0) record(tokenUsage, ctx.usage.cacheRead, {
1484
+ ...tagsBase,
1485
+ "gen_ai.token.type": "cache_read"
1486
+ }, "token.cache_read");
1487
+ if (typeof ctx.usage.cacheCreation === "number" && ctx.usage.cacheCreation > 0) record(tokenUsage, ctx.usage.cacheCreation, {
1488
+ ...tagsBase,
1489
+ "gen_ai.token.type": "cache_creation"
1490
+ }, "token.cache_creation");
1491
+ if (typeof ctx.usage.cost === "number" && ctx.usage.cost > 0) add(costMeter, ctx.usage.cost, tagsBase, "cost");
1492
+ }));
1493
+ unregisters.push(hooks.hook("stream:error", (ctx) => {
1494
+ add(streamErrors, 1, {
1495
+ "http.response.status_code": ctx.statusCode,
1496
+ "error.type": ctx.err instanceof Error ? ctx.err.name : "unknown"
1497
+ }, "stream.error");
1498
+ }));
1499
+ unregisters.push(hooks.hook("tool:before", (ctx) => {
1500
+ toolStart.set(ctx.callId, Date.now());
1501
+ }));
1502
+ unregisters.push(hooks.hook("tool:after", (ctx) => {
1503
+ const started = toolStart.get(ctx.callId);
1504
+ toolStart.delete(ctx.callId);
1505
+ const tags = { "gen_ai.tool.name": ctx.name };
1506
+ if (typeof started === "number") record(toolDuration, Date.now() - started, tags, "tool.duration");
1507
+ record(toolOutput, ctx.outputBytes, tags, "tool.output_bytes");
1508
+ }));
1509
+ unregisters.push(hooks.hook("tool:error", (ctx) => {
1510
+ add(toolErrors, 1, {
1511
+ "gen_ai.tool.name": ctx.name,
1512
+ "error.type": ctx.error.name
1513
+ }, "tool.errors");
1514
+ }));
1515
+ unregisters.push(hooks.hook("tool:cancelled", (ctx) => {
1516
+ toolStart.delete(ctx.callId);
1517
+ }));
1518
+ unregisters.push(hooks.hook("tool:dispatched", (ctx) => {
1519
+ add(toolCalls, 1, {
1520
+ "gen_ai.tool.name": ctx.name,
1521
+ "outcome": ctx.outcome
1522
+ }, "tool.calls");
1523
+ if (ctx.outcome === "gate-block") add(gateBlocks, 1, {
1524
+ "gen_ai.tool.name": ctx.name,
1525
+ ...ctx.reason ? { reason: ctx.reason } : {}
1526
+ }, "gate.blocks");
1527
+ if (ctx.outcome === "unknown") toolStart.delete(ctx.callId);
1528
+ }));
1529
+ unregisters.push(hooks.hook("validation:reject", (ctx) => {
1530
+ add(validationRejects, 1, { "gen_ai.tool.name": ctx.name }, "validation.rejects");
1531
+ }));
1532
+ unregisters.push(hooks.hook("validation:coerce", (ctx) => {
1533
+ add(validationCoercions, 1, {
1534
+ "gen_ai.tool.name": ctx.name,
1535
+ "coercions": ctx.coercions.length
1536
+ }, "validation.coercions");
1537
+ }));
1538
+ unregisters.push(hooks.hook("budget:exceeded", (ctx) => {
1539
+ add(budgetExceeded, 1, {
1540
+ bytes: ctx.bytes,
1541
+ budget: ctx.budget
1542
+ }, "budget.exceeded");
1543
+ }));
1544
+ unregisters.push(hooks.hook("tool-budget:exceeded", (ctx) => {
1545
+ add(toolBudgetExceeded, 1, {
1546
+ "gen_ai.tool.name": ctx.tool,
1547
+ "mode": ctx.mode,
1548
+ "count": ctx.count,
1549
+ "max": ctx.max
1550
+ }, "tool_budget.exceeded");
1551
+ }));
1552
+ unregisters.push(hooks.hook("pairing:repair", (ctx) => {
1553
+ add(pairingRepairs, 1, { mode: ctx.mode }, "pairing.repairs");
1554
+ }));
1555
+ unregisters.push(hooks.hook("oauth:refresh", (ctx) => {
1556
+ add(oauthRefreshes, 1, {
1557
+ provider: ctx.provider,
1558
+ source: ctx.source
1559
+ }, "oauth.refreshes");
1560
+ }));
1561
+ unregisters.push(hooks.hook("mcp:tool:before", (ctx) => {
1562
+ mcpStart.set(ctx.callId, Date.now());
1563
+ }));
1564
+ unregisters.push(hooks.hook("mcp:tool:after", (ctx) => {
1565
+ const started = mcpStart.get(ctx.callId);
1566
+ mcpStart.delete(ctx.callId);
1567
+ const tags = {
1568
+ "gen_ai.mcp.server": ctx.server,
1569
+ "gen_ai.tool.name": ctx.displayName
1570
+ };
1571
+ if (typeof started === "number") record(mcpToolDuration, Date.now() - started, tags, "mcp.tool.duration");
1572
+ }));
1573
+ unregisters.push(hooks.hook("mcp:tool:error", (ctx) => {
1574
+ mcpStart.delete(ctx.callId);
1575
+ add(mcpToolErrors, 1, {
1576
+ "gen_ai.mcp.server": ctx.server,
1577
+ "gen_ai.tool.name": ctx.displayName,
1578
+ "error.type": ctx.error.name
1579
+ }, "mcp.tool.errors");
1580
+ }));
1581
+ unregisters.push(hooks.hook("mcp:bootstrap:end", (ctx) => {
1582
+ record(mcpBootstrapDuration, ctx.durationMs, {
1583
+ "gen_ai.mcp.server": ctx.name,
1584
+ "ok": ctx.ok
1585
+ }, "mcp.bootstrap.duration");
1586
+ }));
1587
+ unregisters.push(hooks.hook("mcp:error", (ctx) => {
1588
+ add(mcpErrors, 1, {
1589
+ "gen_ai.mcp.server": ctx.name,
1590
+ "error.type": ctx.error.name
1591
+ }, "mcp.errors");
1592
+ }));
1593
+ unregisters.push(hooks.hook("mcp:auth:required", (ctx) => {
1594
+ add(mcpAuthRequired, 1, {
1595
+ "gen_ai.mcp.server": ctx.name,
1596
+ "transport": ctx.transport,
1597
+ "reason": ctx.reason
1598
+ }, "mcp.auth.required");
1599
+ }));
1600
+ let disposed = false;
1601
+ return function uninstall() {
1602
+ if (disposed) return;
1603
+ disposed = true;
1604
+ for (const un of unregisters) try {
1605
+ un();
1606
+ } catch {}
1607
+ turnStart.clear();
1608
+ toolStart.clear();
1609
+ mcpStart.clear();
1610
+ };
1611
+ } };
1612
+ }
1613
+ /**
1614
+ * Defensive factory wrapper — returns undefined when meter creation
1615
+ * throws. Lets a half-broken meter (one unsupported instrument) still
1616
+ * give partial metrics rather than collapsing the whole install.
1617
+ */
1618
+ function safeFactory(factory, name, onError) {
1619
+ try {
1620
+ return factory();
1621
+ } catch (err) {
1622
+ try {
1623
+ onError(`createInstrument:${name}`, err);
1624
+ } catch {}
1625
+ return;
1626
+ }
1627
+ }
1628
+ //#endregion
1629
+ //#region src/mutation-validation.ts
1630
+ function defaultFailureMessage(failure) {
1631
+ const changed = failure.batch.mutations.map((m) => `${m.displayName} ${String(m.input.path ?? m.callId)}`).join(", ");
1632
+ const details = failure.result.message ?? failure.result.errors?.join("\n") ?? "Validation failed after the last file mutation.";
1633
+ return `[Mutation validation failed${failure.failureCount > 1 ? ` (failure ${failure.failureCount})` : ""}] ${changed}\n${details}\nFix the validation errors with the smallest focused change.`;
1634
+ }
1635
+ function isSuccessfulMutation(outcome) {
1636
+ return outcome === "created" || outcome === "updated" || outcome === "edited";
1637
+ }
1638
+ /**
1639
+ * Build host-side hooks for post-mutation validation without changing the
1640
+ * built-in edit/write tools. The helper only observes successful mutations
1641
+ * reported through `tool:after.outcome`; validation commands and recovery
1642
+ * prompts remain host policy.
1643
+ */
1644
+ function createMutationValidationHooks(options) {
1645
+ const pending = [];
1646
+ let failureCount = 0;
1647
+ const maxFailures = Math.max(1, Math.floor(options.maxFailuresPerRun ?? 2));
1648
+ return {
1649
+ "tool:after": (ctx) => {
1650
+ if (!isSuccessfulMutation(ctx.outcome)) return;
1651
+ pending.push({
1652
+ turnId: ctx.turnId,
1653
+ callId: ctx.callId,
1654
+ tool: ctx.name,
1655
+ displayName: ctx.displayName,
1656
+ input: ctx.input,
1657
+ outcome: ctx.outcome,
1658
+ result: ctx.result
1659
+ });
1660
+ },
1661
+ "tool-results:after": async (ctx) => {
1662
+ if (pending.length === 0) return;
1663
+ const mutations = pending.splice(0);
1664
+ const batch = {
1665
+ turnId: ctx.turnId,
1666
+ mutations
1667
+ };
1668
+ let result;
1669
+ try {
1670
+ result = await options.validate(batch);
1671
+ } catch (err) {
1672
+ await options.onError?.(err, batch);
1673
+ return;
1674
+ }
1675
+ if (!result || result.ok) return;
1676
+ failureCount++;
1677
+ if (failureCount > maxFailures) return;
1678
+ const withoutMessage = {
1679
+ batch,
1680
+ result,
1681
+ failureCount
1682
+ };
1683
+ const message = options.buildFailureMessage?.(withoutMessage) ?? defaultFailureMessage(withoutMessage);
1684
+ await options.onFailure?.({
1685
+ ...withoutMessage,
1686
+ message
1687
+ });
1688
+ },
1689
+ "agent:done": () => {
1690
+ pending.length = 0;
1691
+ failureCount = 0;
1692
+ }
1693
+ };
1694
+ }
1695
+ //#endregion
1696
+ //#region src/tracing.ts
1697
+ /**
1698
+ * Stable keys we emit unconditionally — present in BOTH Sentry and OTel
1699
+ * conventions with identical semantics, so no `conventions` branching is
1700
+ * needed at the call site.
1701
+ */
1702
+ const GEN_AI = {
1703
+ system: "gen_ai.system",
1704
+ operationName: "gen_ai.operation.name",
1705
+ requestModel: "gen_ai.request.model",
1706
+ responseModel: "gen_ai.response.model",
1707
+ responseFinishReasons: "gen_ai.response.finish_reasons",
1708
+ responseId: "gen_ai.response.id",
1709
+ responseStreaming: "gen_ai.response.streaming",
1710
+ responseTokensPerSecond: "gen_ai.response.tokens_per_second",
1711
+ responseTimeToFirstTokenSeconds: "gen_ai.response.time_to_first_token",
1712
+ responseTimeToFirstTokenMs: "gen_ai.client.time_to_first_token",
1713
+ usageInputTokens: "gen_ai.usage.input_tokens",
1714
+ usageOutputTokens: "gen_ai.usage.output_tokens",
1715
+ usageTotalTokens: "gen_ai.usage.total_tokens",
1716
+ usageInputTokensCached: "gen_ai.usage.input_tokens.cached",
1717
+ usageInputTokensCacheWrite: "gen_ai.usage.input_tokens.cache_write",
1718
+ usageOutputTokensReasoning: "gen_ai.usage.output_tokens.reasoning",
1719
+ usageCacheReadInputTokens: "gen_ai.usage.cache_read_input_tokens",
1720
+ usageCacheCreationInputTokens: "gen_ai.usage.cache_creation_input_tokens",
1721
+ usageReasoningTokens: "gen_ai.usage.reasoning_tokens",
1722
+ costTotalTokens: "gen_ai.cost.total_tokens",
1723
+ costInputTokens: "gen_ai.cost.input_tokens",
1724
+ costOutputTokens: "gen_ai.cost.output_tokens",
1725
+ usageCostUsd: "gen_ai.usage.cost_usd",
1726
+ toolName: "gen_ai.tool.name",
1727
+ toolDescription: "gen_ai.tool.description",
1728
+ toolType: "gen_ai.tool.type",
1729
+ toolCallId: "gen_ai.tool.call.id",
1730
+ toolCallArguments: "gen_ai.tool.call.arguments",
1731
+ toolCallResult: "gen_ai.tool.call.result",
1732
+ toolMessage: "gen_ai.tool.message",
1733
+ toolInputDeprecated: "gen_ai.tool.input",
1734
+ toolOutputDeprecated: "gen_ai.tool.output",
1735
+ requestMaxTokens: "gen_ai.request.max_tokens",
1736
+ requestTemperature: "gen_ai.request.temperature",
1737
+ requestTopP: "gen_ai.request.top_p",
1738
+ requestTopK: "gen_ai.request.top_k",
1739
+ requestSeed: "gen_ai.request.seed",
1740
+ requestFrequencyPenalty: "gen_ai.request.frequency_penalty",
1741
+ requestPresencePenalty: "gen_ai.request.presence_penalty",
1742
+ inputMessages: "gen_ai.input.messages",
1743
+ outputMessages: "gen_ai.output.messages",
1744
+ systemInstructions: "gen_ai.system_instructions",
1745
+ toolDefinitions: "gen_ai.tool.definitions",
1746
+ agentName: "gen_ai.agent.name",
1747
+ agentRunId: "gen_ai.agent.run.id",
1748
+ agentParentRunId: "gen_ai.agent.parent_run.id",
1749
+ agentDepth: "gen_ai.agent.depth",
1750
+ pipelineName: "gen_ai.pipeline.name",
1751
+ turn: "gen_ai.agent.turn",
1752
+ mcpServer: "gen_ai.mcp.server",
1753
+ mcpToolCount: "gen_ai.mcp.tool_count"
1754
+ };
1755
+ /** Compose namespace prefix once. */
1756
+ function prefixed(namespace, name) {
1757
+ return namespace ? `${namespace}/${name}` : name;
1758
+ }
1759
+ /** Drop undefined entries — tracers vary on how they render `undefined`. */
1760
+ function compact(attrs) {
1761
+ const out = {};
1762
+ for (const [k, v] of Object.entries(attrs)) if (v !== void 0) out[k] = v;
1763
+ return out;
1764
+ }
1765
+ function blockToParts(block) {
1766
+ switch (block.type) {
1767
+ case "text": return [{
1768
+ type: "text",
1769
+ content: typeof block.text === "string" ? block.text : ""
1770
+ }];
1771
+ case "image":
1772
+ case "audio":
1773
+ case "video":
1774
+ case "document": return [{
1775
+ type: "text",
1776
+ content: "[Blob substitute]"
1777
+ }];
1778
+ case "tool_call": return [{
1779
+ type: "tool_call",
1780
+ ...typeof block.name === "string" ? { name: block.name } : {},
1781
+ ...typeof block.id === "string" ? { call_id: block.id } : {},
1782
+ arguments: safeJson(block.input)
1783
+ }];
1784
+ case "tool_result": {
1785
+ const output = block.output;
1786
+ const text = typeof output === "string" ? output : Array.isArray(output) ? output.map((b) => b.type === "image" || b.type === "audio" || b.type === "video" || b.type === "document" ? "[Blob substitute]" : b.text ?? "").join("\n") : "";
1787
+ return [{
1788
+ type: "tool_result",
1789
+ ...typeof block.callId === "string" ? { call_id: block.callId } : {},
1790
+ content: text
1791
+ }];
1792
+ }
1793
+ case "thinking": return [{
1794
+ type: "reasoning",
1795
+ content: typeof block.text === "string" ? block.text : ""
1796
+ }];
1797
+ default: return [];
1798
+ }
1799
+ }
1800
+ function messageToSentry(msg) {
1801
+ const parts = [];
1802
+ for (const block of msg.content) parts.push(...blockToParts(block));
1803
+ return {
1804
+ role: msg.role,
1805
+ parts
1806
+ };
1807
+ }
1808
+ function safeJson(value) {
1809
+ try {
1810
+ return JSON.stringify(value ?? null);
1811
+ } catch {
1812
+ return "\"[unserializable]\"";
1813
+ }
1814
+ }
1815
+ /**
1816
+ * Stringify an array of `SessionMessage`s into Sentry's `gen_ai.input.messages`
1817
+ * format. Each entry's role + content is mapped via `messageToSentry`.
1818
+ * Returns `undefined` when the input is empty or unserializable.
1819
+ */
1820
+ function stringifyMessages(messages) {
1821
+ if (messages.length === 0) return void 0;
1822
+ try {
1823
+ const sentry = messages.map((m) => messageToSentry({
1824
+ role: m.role,
1825
+ content: m.content
1826
+ }));
1827
+ return JSON.stringify(sentry);
1828
+ } catch {
1829
+ return;
1830
+ }
1831
+ }
1832
+ /**
1833
+ * Stringify a `StreamOptions.tools` array into Sentry's
1834
+ * `gen_ai.tool.definitions` format: `[{name, description?, parameters?}, …]`.
1835
+ *
1836
+ * The input is `unknown[]` because `StreamOptions.tools` carries the
1837
+ * provider-formatted shape (Anthropic / OpenAI / OpenAI-compat all
1838
+ * differ). We probe the three names that overlap (`name`, `description`,
1839
+ * `inputSchema` / `parameters`) and degrade gracefully otherwise.
1840
+ */
1841
+ function stringifyToolDefs(tools) {
1842
+ if (!tools || tools.length === 0) return void 0;
1843
+ try {
1844
+ return JSON.stringify(tools.map((raw) => {
1845
+ if (!raw || typeof raw !== "object") return { name: "anonymous" };
1846
+ const t = raw;
1847
+ const name = typeof t.name === "string" ? t.name : "anonymous";
1848
+ const description = typeof t.description === "string" ? t.description : void 0;
1849
+ const parameters = t.parameters ?? t.input_schema ?? t.inputSchema;
1850
+ return {
1851
+ name,
1852
+ ...description ? { description } : {},
1853
+ ...parameters !== void 0 ? { parameters } : {}
1854
+ };
1855
+ }));
1856
+ } catch {
1857
+ return;
1858
+ }
1859
+ }
1860
+ /**
1861
+ * Build the usage attribute set per the configured conventions. Sentry
1862
+ * expects `input_tokens` to include cached, with `input_tokens.cached`
1863
+ * + `input_tokens.cache_write` subkeys; OTel splits cache into
1864
+ * separate top-level keys. `'both'` emits everything.
1865
+ */
1866
+ function buildUsageAttrs(usage, conventions) {
1867
+ const cacheRead = usage.cacheRead ?? 0;
1868
+ const cacheWrite = usage.cacheCreation ?? 0;
1869
+ const inputInclCache = usage.input + cacheRead + cacheWrite;
1870
+ const totalTokens = inputInclCache + usage.output;
1871
+ const wantSentry = conventions === "sentry" || conventions === "both";
1872
+ const wantOtel = conventions === "otel" || conventions === "both";
1873
+ const out = {
1874
+ [GEN_AI.responseModel]: usage.modelId,
1875
+ [GEN_AI.responseFinishReasons]: usage.finishReason ? [usage.finishReason] : void 0,
1876
+ [GEN_AI.usageOutputTokens]: usage.output,
1877
+ [GEN_AI.usageTotalTokens]: totalTokens
1878
+ };
1879
+ if (wantSentry) {
1880
+ out[GEN_AI.usageInputTokens] = inputInclCache;
1881
+ if (cacheRead > 0) out[GEN_AI.usageInputTokensCached] = cacheRead;
1882
+ if (cacheWrite > 0) out[GEN_AI.usageInputTokensCacheWrite] = cacheWrite;
1883
+ if (typeof usage.thinking === "number" && usage.thinking > 0) out[GEN_AI.usageOutputTokensReasoning] = usage.thinking;
1884
+ if (typeof usage.cost === "number") out[GEN_AI.costTotalTokens] = usage.cost;
1885
+ if (typeof usage.timeToFirstTokenMs === "number") out[GEN_AI.responseTimeToFirstTokenSeconds] = usage.timeToFirstTokenMs / 1e3;
1886
+ }
1887
+ if (wantOtel) {
1888
+ if (!wantSentry) out[GEN_AI.usageInputTokens] = usage.input;
1889
+ if (cacheRead > 0) out[GEN_AI.usageCacheReadInputTokens] = cacheRead;
1890
+ if (cacheWrite > 0) out[GEN_AI.usageCacheCreationInputTokens] = cacheWrite;
1891
+ if (typeof usage.thinking === "number" && usage.thinking > 0) out[GEN_AI.usageReasoningTokens] = usage.thinking;
1892
+ if (typeof usage.cost === "number") out[GEN_AI.usageCostUsd] = usage.cost;
1893
+ if (typeof usage.timeToFirstTokenMs === "number") out[GEN_AI.responseTimeToFirstTokenMs] = usage.timeToFirstTokenMs;
1894
+ }
1895
+ return out;
1896
+ }
1897
+ /**
1898
+ * Build a set of tracing hook handlers that can be installed on an agent.
1899
+ *
1900
+ * @example Sentry
1901
+ * ```ts
1902
+ * const tracing = createTracingHooks({
1903
+ * startSpan: (name, attrs) => Sentry.startInactiveSpan({ name, attributes: attrs }),
1904
+ * })
1905
+ * const uninstall = tracing.install(agent.hooks)
1906
+ * try { await agent.run({ prompt }) }
1907
+ * finally { uninstall() }
1908
+ * ```
1909
+ *
1910
+ * @example OpenTelemetry with parent-context propagation
1911
+ * ```ts
1912
+ * import { trace, context, propagation } from '@opentelemetry/api'
1913
+ *
1914
+ * const tracer = trace.getTracer('zidane')
1915
+ * const tracing = createTracingHooks({
1916
+ * startSpan: (name, attrs, parentCtx) => {
1917
+ * const ctx = parentCtx ? propagation.extract(context.active(), parentCtx) : context.active()
1918
+ * return tracer.startSpan(name, { attributes: attrs }, ctx)
1919
+ * },
1920
+ * getActiveTraceContext: () => {
1921
+ * const carrier: Record<string, string> = {}
1922
+ * propagation.inject(context.active(), carrier)
1923
+ * return carrier
1924
+ * },
1925
+ * })
1926
+ * ```
1927
+ */
1928
+ function createTracingHooks(options) {
1929
+ const namespace = options.namespace;
1930
+ const legacy = options.legacyAttributes ?? true;
1931
+ const conventions = options.conventions ?? "sentry";
1932
+ const wantSentry = conventions === "sentry" || conventions === "both";
1933
+ const captureContent = options.captureMessageContent !== false;
1934
+ const onError = options.onError ?? (() => {});
1935
+ return { install(hooks) {
1936
+ const runSpans = /* @__PURE__ */ new Map();
1937
+ const turnSpans = /* @__PURE__ */ new Map();
1938
+ const toolSpans = /* @__PURE__ */ new Map();
1939
+ const mcpSpans = /* @__PURE__ */ new Map();
1940
+ const spawnSpans = /* @__PURE__ */ new Map();
1941
+ const bootstrapSpans = /* @__PURE__ */ new Map();
1942
+ let activeSystem;
1943
+ const turnTrack = /* @__PURE__ */ new Map();
1944
+ let activeRunSpan;
1945
+ function safeStartSpan(name, attrs, parentContext) {
1946
+ try {
1947
+ return options.startSpan(prefixed(namespace, name), compact(attrs), parentContext);
1948
+ } catch (err) {
1949
+ try {
1950
+ onError("startSpan", err);
1951
+ } catch {}
1952
+ return;
1953
+ }
1954
+ }
1955
+ function safeSetAttrs(span, attrs) {
1956
+ if (!span?.setAttributes) return;
1957
+ try {
1958
+ span.setAttributes(compact(attrs));
1959
+ } catch (err) {
1960
+ try {
1961
+ onError("setAttributes", err);
1962
+ } catch {}
1963
+ }
1964
+ }
1965
+ function safeEnd(map, key) {
1966
+ const span = map.get(key);
1967
+ if (!span) return;
1968
+ try {
1969
+ span.end();
1970
+ } catch (err) {
1971
+ try {
1972
+ onError("end", err);
1973
+ } catch {}
1974
+ }
1975
+ map.delete(key);
1976
+ }
1977
+ function endAll(map) {
1978
+ for (const [, span] of map) try {
1979
+ span.end();
1980
+ } catch (err) {
1981
+ try {
1982
+ onError("end", err);
1983
+ } catch {}
1984
+ }
1985
+ map.clear();
1986
+ }
1987
+ function safeAddEvent(span, name, attrs) {
1988
+ if (!span) return;
1989
+ if (typeof span.addEvent === "function") {
1990
+ try {
1991
+ span.addEvent(name, compact(attrs));
1992
+ } catch (err) {
1993
+ try {
1994
+ onError("addEvent", err);
1995
+ } catch {}
1996
+ }
1997
+ return;
1998
+ }
1999
+ safeSetAttrs(span, Object.fromEntries(Object.entries(compact(attrs)).map(([k, v]) => [`event.${name}.${k}`, v])));
2000
+ }
2001
+ function redact(kind, value, meta) {
2002
+ if (typeof options.redact !== "function") return value;
2003
+ try {
2004
+ return options.redact(kind, value, meta);
2005
+ } catch (err) {
2006
+ try {
2007
+ onError("redact", err);
2008
+ } catch {}
2009
+ return value;
2010
+ }
2011
+ }
2012
+ const unregisters = [];
2013
+ unregisters.push(hooks.hook("agent:start", (ctx) => {
2014
+ activeSystem = ctx.providerName;
2015
+ const span = safeStartSpan(`invoke_agent ${ctx.agentName && ctx.agentName.length > 0 ? ctx.agentName : "agent"}`, {
2016
+ [GEN_AI.operationName]: "invoke_agent",
2017
+ [GEN_AI.system]: activeSystem,
2018
+ [GEN_AI.agentName]: ctx.agentName,
2019
+ [GEN_AI.agentRunId]: ctx.runId,
2020
+ [GEN_AI.agentParentRunId]: ctx.parentRunId,
2021
+ [GEN_AI.agentDepth]: ctx.depth,
2022
+ "sentry.op": "gen_ai.invoke_agent",
2023
+ ...legacy ? {
2024
+ runId: ctx.runId,
2025
+ parentRunId: ctx.parentRunId,
2026
+ agentName: ctx.agentName,
2027
+ depth: ctx.depth
2028
+ } : {}
2029
+ }, ctx.tracingContext);
2030
+ if (span) {
2031
+ runSpans.set(ctx.runId, span);
2032
+ activeRunSpan = span;
2033
+ }
2034
+ }));
2035
+ unregisters.push(hooks.hook("agent:done", (stats) => {
2036
+ const usageAttrs = buildUsageAttrs({
2037
+ input: stats.totalIn,
2038
+ output: stats.totalOut,
2039
+ cacheRead: stats.totalCacheRead,
2040
+ cacheCreation: stats.totalCacheCreation,
2041
+ cost: stats.cost,
2042
+ timeToFirstTokenMs: stats.timeTillFirstTokenMs
2043
+ }, conventions);
2044
+ for (const [, span] of runSpans) safeSetAttrs(span, {
2045
+ ...usageAttrs,
2046
+ [GEN_AI.turn]: stats.turns,
2047
+ "gen_ai.agent.elapsed_ms": stats.elapsed,
2048
+ ...legacy ? {
2049
+ totalIn: stats.totalIn,
2050
+ totalOut: stats.totalOut,
2051
+ totalCacheRead: stats.totalCacheRead,
2052
+ totalCacheCreation: stats.totalCacheCreation,
2053
+ cost: stats.cost,
2054
+ turns: stats.turns,
2055
+ elapsed: stats.elapsed
2056
+ } : {}
2057
+ });
2058
+ endAll(runSpans);
2059
+ endAll(turnSpans);
2060
+ endAll(toolSpans);
2061
+ endAll(mcpSpans);
2062
+ endAll(spawnSpans);
2063
+ endAll(bootstrapSpans);
2064
+ activeRunSpan = void 0;
2065
+ activeSystem = void 0;
2066
+ turnTrack.clear();
2067
+ }));
2068
+ unregisters.push(hooks.hook("agent:abort", () => {
2069
+ for (const [, span] of runSpans) safeSetAttrs(span, { "gen_ai.agent.status": "aborted" });
2070
+ }));
2071
+ unregisters.push(hooks.hook("turn:before", (ctx) => {
2072
+ const requestedModel = ctx.options.model;
2073
+ const baseAttrs = {
2074
+ [GEN_AI.operationName]: "chat",
2075
+ [GEN_AI.system]: activeSystem,
2076
+ [GEN_AI.requestModel]: requestedModel,
2077
+ [GEN_AI.turn]: ctx.turn,
2078
+ [GEN_AI.responseStreaming]: true,
2079
+ [GEN_AI.requestMaxTokens]: ctx.options.maxTokens,
2080
+ "sentry.op": "gen_ai.chat",
2081
+ "gen_ai.agent.turn_id": ctx.turnId,
2082
+ ...legacy ? {
2083
+ turnId: ctx.turnId,
2084
+ turn: ctx.turn
2085
+ } : {}
2086
+ };
2087
+ if (captureContent) {
2088
+ const systemText = ctx.options.system;
2089
+ if (systemText) baseAttrs[GEN_AI.systemInstructions] = redact("system", systemText, {
2090
+ turnId: ctx.turnId,
2091
+ turn: ctx.turn
2092
+ });
2093
+ const inputMsgs = stringifyMessages(ctx.options.messages);
2094
+ if (inputMsgs) baseAttrs[GEN_AI.inputMessages] = redact("prompt", inputMsgs, {
2095
+ turnId: ctx.turnId,
2096
+ turn: ctx.turn
2097
+ });
2098
+ const toolDefs = stringifyToolDefs(ctx.options.tools);
2099
+ if (toolDefs) baseAttrs[GEN_AI.toolDefinitions] = toolDefs;
2100
+ }
2101
+ const span = safeStartSpan(`chat${requestedModel ? ` ${requestedModel}` : ""}`, baseAttrs);
2102
+ if (span) {
2103
+ turnSpans.set(ctx.turnId, span);
2104
+ turnTrack.set(ctx.turnId, { startedAt: Date.now() });
2105
+ }
2106
+ }));
2107
+ unregisters.push(hooks.hook("stream:start", (ctx) => {
2108
+ safeAddEvent(turnSpans.get(ctx.turnId), "gen_ai.stream.start", { startedAt: ctx.startedAt });
2109
+ }));
2110
+ unregisters.push(hooks.hook("turn:after", (ctx) => {
2111
+ const span = turnSpans.get(ctx.turnId);
2112
+ const tracked = turnTrack.get(ctx.turnId);
2113
+ turnTrack.delete(ctx.turnId);
2114
+ let tokensPerSecond;
2115
+ if (tracked && ctx.usage.output > 0) {
2116
+ const elapsedMs = Date.now() - tracked.startedAt;
2117
+ if (elapsedMs > 0) tokensPerSecond = ctx.usage.output / elapsedMs * 1e3;
2118
+ }
2119
+ const usageAttrs = buildUsageAttrs(ctx.usage, conventions);
2120
+ const cumInputTokens = wantSentry ? ctx.cumulativeUsage.input + ctx.cumulativeUsage.cacheRead + ctx.cumulativeUsage.cacheCreation : ctx.cumulativeUsage.input;
2121
+ const attrs = {
2122
+ ...usageAttrs,
2123
+ [GEN_AI.responseTokensPerSecond]: tokensPerSecond,
2124
+ "gen_ai.agent.cumulative.input_tokens": cumInputTokens,
2125
+ "gen_ai.agent.cumulative.output_tokens": ctx.cumulativeUsage.output,
2126
+ "gen_ai.agent.cumulative.total_tokens": cumInputTokens + ctx.cumulativeUsage.output,
2127
+ "gen_ai.agent.cumulative.cache_read_input_tokens": ctx.cumulativeUsage.cacheRead,
2128
+ "gen_ai.agent.cumulative.cache_creation_input_tokens": ctx.cumulativeUsage.cacheCreation,
2129
+ "gen_ai.agent.cumulative.cost_usd": ctx.cumulativeUsage.cost,
2130
+ "gen_ai.agent.cumulative.turns": ctx.cumulativeUsage.turns,
2131
+ ...legacy ? {
2132
+ inputTokens: ctx.usage.input,
2133
+ outputTokens: ctx.usage.output,
2134
+ finishReason: ctx.usage.finishReason,
2135
+ modelId: ctx.usage.modelId
2136
+ } : {}
2137
+ };
2138
+ if (captureContent && ctx.message && ctx.message.role !== "system" && ctx.message.role !== "command") {
2139
+ const outputMsgs = stringifyMessages([{
2140
+ role: ctx.message.role,
2141
+ content: ctx.message.content
2142
+ }]);
2143
+ if (outputMsgs) attrs[GEN_AI.outputMessages] = redact("stream-text", outputMsgs, {
2144
+ turnId: ctx.turnId,
2145
+ turn: ctx.turn
2146
+ });
2147
+ }
2148
+ safeSetAttrs(span, attrs);
2149
+ safeEnd(turnSpans, ctx.turnId);
2150
+ }));
2151
+ unregisters.push(hooks.hook("stream:error", (ctx) => {
2152
+ const span = turnSpans.get(ctx.turnId);
2153
+ const message = ctx.err instanceof Error ? ctx.err.message : String(ctx.err);
2154
+ safeSetAttrs(span, {
2155
+ "error.type": ctx.err instanceof Error ? ctx.err.name : "unknown",
2156
+ "error.message": message,
2157
+ "http.response.status_code": ctx.statusCode,
2158
+ "gen_ai.request.id": ctx.requestId
2159
+ });
2160
+ safeAddEvent(span, "gen_ai.stream.error", {
2161
+ "error.type": ctx.err instanceof Error ? ctx.err.name : "unknown",
2162
+ "error.message": message,
2163
+ "http.response.status_code": ctx.statusCode,
2164
+ "gen_ai.request.id": ctx.requestId
2165
+ });
2166
+ }));
2167
+ unregisters.push(hooks.hook("tool:before", (ctx) => {
2168
+ const span = safeStartSpan(`execute_tool ${ctx.displayName}`, {
2169
+ [GEN_AI.operationName]: "execute_tool",
2170
+ [GEN_AI.system]: activeSystem,
2171
+ [GEN_AI.toolName]: ctx.name,
2172
+ [GEN_AI.toolType]: "function",
2173
+ [GEN_AI.toolCallId]: ctx.callId,
2174
+ "sentry.op": "gen_ai.execute_tool",
2175
+ "gen_ai.tool.display_name": ctx.displayName,
2176
+ "gen_ai.agent.turn_id": ctx.turnId,
2177
+ "gen_ai.agent.depth": ctx.depth,
2178
+ "gen_ai.agent.run.id": ctx.runId,
2179
+ "gen_ai.agent.parent_run.id": ctx.parentRunId,
2180
+ ...legacy ? {
2181
+ toolName: ctx.name,
2182
+ displayName: ctx.displayName,
2183
+ turnId: ctx.turnId,
2184
+ callId: ctx.callId
2185
+ } : {}
2186
+ });
2187
+ if (span) {
2188
+ toolSpans.set(ctx.callId, span);
2189
+ if (captureContent) try {
2190
+ const redacted = redact("tool-input", JSON.stringify(ctx.input), {
2191
+ toolName: ctx.name,
2192
+ displayName: ctx.displayName,
2193
+ callId: ctx.callId,
2194
+ turnId: ctx.turnId
2195
+ });
2196
+ const attrs = {};
2197
+ if (wantSentry) attrs[GEN_AI.toolCallArguments] = redacted;
2198
+ if (conventions === "otel" || conventions === "both") attrs[GEN_AI.toolInputDeprecated] = redacted;
2199
+ safeSetAttrs(span, attrs);
2200
+ } catch {}
2201
+ }
2202
+ }));
2203
+ unregisters.push(hooks.hook("tool:after", (ctx) => {
2204
+ const span = toolSpans.get(ctx.callId);
2205
+ safeSetAttrs(span, {
2206
+ "gen_ai.tool.output_bytes": ctx.outputBytes,
2207
+ "gen_ai.tool.coercions": ctx.coercions?.length ?? 0
2208
+ });
2209
+ if (captureContent && typeof ctx.result === "string") {
2210
+ const redacted = redact("tool-result", ctx.result, {
2211
+ toolName: ctx.name,
2212
+ callId: ctx.callId
2213
+ });
2214
+ const attrs = {};
2215
+ if (wantSentry) attrs[GEN_AI.toolCallResult] = redacted;
2216
+ if (conventions === "otel" || conventions === "both") attrs[GEN_AI.toolOutputDeprecated] = redacted;
2217
+ safeSetAttrs(span, attrs);
2218
+ }
2219
+ safeEnd(toolSpans, ctx.callId);
2220
+ }));
2221
+ unregisters.push(hooks.hook("tool:error", (ctx) => {
2222
+ safeSetAttrs(toolSpans.get(ctx.callId), {
2223
+ "error.type": ctx.error.name,
2224
+ "error.message": ctx.error.message
2225
+ });
2226
+ safeEnd(toolSpans, ctx.callId);
2227
+ }));
2228
+ unregisters.push(hooks.hook("tool:dispatched", (ctx) => {
2229
+ if (ctx.outcome === "gate-block") safeAddEvent(activeRunSpan, "gen_ai.gate.block", {
2230
+ [GEN_AI.toolName]: ctx.name,
2231
+ [GEN_AI.toolCallId]: ctx.callId,
2232
+ reason: ctx.reason
2233
+ });
2234
+ else if (ctx.outcome === "gate-substitute") safeAddEvent(activeRunSpan, "gen_ai.gate.substitute", {
2235
+ [GEN_AI.toolName]: ctx.name,
2236
+ [GEN_AI.toolCallId]: ctx.callId
2237
+ });
2238
+ else if (ctx.outcome === "unknown") safeAddEvent(activeRunSpan, "gen_ai.tool.unknown", {
2239
+ [GEN_AI.toolName]: ctx.name,
2240
+ [GEN_AI.toolCallId]: ctx.callId
2241
+ });
2242
+ else if (ctx.outcome === "invalid-input") safeAddEvent(activeRunSpan, "gen_ai.tool.invalid_input", {
2243
+ [GEN_AI.toolName]: ctx.name,
2244
+ [GEN_AI.toolCallId]: ctx.callId
2245
+ });
2246
+ }));
2247
+ unregisters.push(hooks.hook("validation:reject", (ctx) => {
2248
+ safeAddEvent(activeRunSpan, "gen_ai.validation.reject", {
2249
+ [GEN_AI.toolName]: ctx.name,
2250
+ [GEN_AI.toolCallId]: ctx.callId,
2251
+ reason: ctx.reason
2252
+ });
2253
+ }));
2254
+ unregisters.push(hooks.hook("validation:coerce", (ctx) => {
2255
+ safeAddEvent(activeRunSpan, "gen_ai.validation.coerce", {
2256
+ [GEN_AI.toolName]: ctx.name,
2257
+ [GEN_AI.toolCallId]: ctx.callId,
2258
+ coercions: ctx.coercions
2259
+ });
2260
+ }));
2261
+ unregisters.push(hooks.hook("budget:exceeded", (ctx) => {
2262
+ safeAddEvent(activeRunSpan, "gen_ai.budget.exceeded", {
2263
+ turnId: ctx.turnId,
2264
+ bytes: ctx.bytes,
2265
+ budget: ctx.budget
2266
+ });
2267
+ }));
2268
+ unregisters.push(hooks.hook("tool-budget:exceeded", (ctx) => {
2269
+ safeAddEvent(activeRunSpan, "gen_ai.tool_budget.exceeded", {
2270
+ [GEN_AI.toolName]: ctx.tool,
2271
+ turnId: ctx.turnId,
2272
+ count: ctx.count,
2273
+ max: ctx.max,
2274
+ mode: ctx.mode
2275
+ });
2276
+ }));
2277
+ unregisters.push(hooks.hook("pairing:repair", (ctx) => {
2278
+ safeAddEvent(activeRunSpan, "gen_ai.pairing.repair", {
2279
+ mode: ctx.mode,
2280
+ callId: ctx.callId,
2281
+ turnId: ctx.turnId
2282
+ });
2283
+ }));
2284
+ unregisters.push(hooks.hook("oauth:refresh", (ctx) => {
2285
+ safeAddEvent(activeRunSpan, "gen_ai.oauth.refresh", {
2286
+ provider: ctx.provider,
2287
+ providerId: ctx.providerId,
2288
+ source: ctx.source
2289
+ });
2290
+ }));
2291
+ unregisters.push(hooks.hook("mcp:tool:before", (ctx) => {
2292
+ const span = safeStartSpan(`execute_tool ${ctx.server}.${ctx.tool}`, {
2293
+ [GEN_AI.operationName]: "execute_tool",
2294
+ [GEN_AI.system]: activeSystem,
2295
+ [GEN_AI.toolName]: ctx.displayName,
2296
+ [GEN_AI.toolType]: "extension",
2297
+ [GEN_AI.toolCallId]: ctx.callId,
2298
+ [GEN_AI.mcpServer]: ctx.server,
2299
+ "sentry.op": "gen_ai.execute_tool",
2300
+ "gen_ai.mcp.tool": ctx.tool,
2301
+ "gen_ai.agent.turn_id": ctx.turnId,
2302
+ "gen_ai.agent.run.id": ctx.runId,
2303
+ ...legacy ? {
2304
+ server: ctx.server,
2305
+ tool: ctx.tool,
2306
+ displayName: ctx.displayName,
2307
+ turnId: ctx.turnId,
2308
+ callId: ctx.callId
2309
+ } : {}
2310
+ });
2311
+ if (span) {
2312
+ mcpSpans.set(ctx.callId, span);
2313
+ if (captureContent) try {
2314
+ const redacted = redact("mcp-tool-input", JSON.stringify(ctx.input), {
2315
+ server: ctx.server,
2316
+ tool: ctx.tool,
2317
+ callId: ctx.callId
2318
+ });
2319
+ const attrs = {};
2320
+ if (wantSentry) attrs[GEN_AI.toolCallArguments] = redacted;
2321
+ if (conventions === "otel" || conventions === "both") attrs[GEN_AI.toolInputDeprecated] = redacted;
2322
+ safeSetAttrs(span, attrs);
2323
+ } catch {}
2324
+ }
2325
+ }));
2326
+ unregisters.push(hooks.hook("mcp:tool:after", (ctx) => {
2327
+ const span = mcpSpans.get(ctx.callId);
2328
+ safeSetAttrs(span, { "gen_ai.tool.output_bytes": ctx.outputBytes });
2329
+ if (captureContent && typeof ctx.result === "string") {
2330
+ const redacted = redact("mcp-tool-result", ctx.result, {
2331
+ server: ctx.server,
2332
+ tool: ctx.tool,
2333
+ callId: ctx.callId
2334
+ });
2335
+ const attrs = {};
2336
+ if (wantSentry) attrs[GEN_AI.toolCallResult] = redacted;
2337
+ if (conventions === "otel" || conventions === "both") attrs[GEN_AI.toolOutputDeprecated] = redacted;
2338
+ safeSetAttrs(span, attrs);
2339
+ }
2340
+ safeEnd(mcpSpans, ctx.callId);
2341
+ }));
2342
+ unregisters.push(hooks.hook("mcp:tool:error", (ctx) => {
2343
+ safeSetAttrs(mcpSpans.get(ctx.callId), {
2344
+ "error.type": ctx.error.name,
2345
+ "error.message": ctx.error.message
2346
+ });
2347
+ safeEnd(mcpSpans, ctx.callId);
2348
+ }));
2349
+ unregisters.push(hooks.hook("mcp:bootstrap:start", (ctx) => {
2350
+ const span = safeStartSpan(`mcp.bootstrap ${ctx.name}`, {
2351
+ [GEN_AI.operationName]: "mcp.bootstrap",
2352
+ [GEN_AI.system]: activeSystem,
2353
+ [GEN_AI.mcpServer]: ctx.name,
2354
+ "gen_ai.mcp.transport": ctx.transport
2355
+ });
2356
+ if (span) bootstrapSpans.set(ctx.name, span);
2357
+ }));
2358
+ unregisters.push(hooks.hook("mcp:bootstrap:end", (ctx) => {
2359
+ const span = bootstrapSpans.get(ctx.name);
2360
+ const ok = ctx.ok;
2361
+ safeSetAttrs(span, {
2362
+ "gen_ai.mcp.bootstrap.duration_ms": ctx.durationMs,
2363
+ "gen_ai.mcp.bootstrap.ok": ok,
2364
+ ...ok ? {
2365
+ [GEN_AI.mcpToolCount]: ctx.toolCount,
2366
+ "gen_ai.mcp.lazy": ctx.lazy,
2367
+ "gen_ai.mcp.cached": ctx.cached
2368
+ } : {
2369
+ "error.type": ctx.error.name,
2370
+ "error.message": ctx.error.message
2371
+ }
2372
+ });
2373
+ safeEnd(bootstrapSpans, ctx.name);
2374
+ }));
2375
+ unregisters.push(hooks.hook("mcp:auth:required", (ctx) => {
2376
+ safeAddEvent(bootstrapSpans.get(ctx.name) ?? activeRunSpan, "gen_ai.mcp.auth.required", {
2377
+ server: ctx.name,
2378
+ transport: ctx.transport,
2379
+ reason: ctx.reason
2380
+ });
2381
+ }));
2382
+ unregisters.push(hooks.hook("mcp:error", (ctx) => {
2383
+ safeAddEvent(activeRunSpan, "gen_ai.mcp.error", {
2384
+ "server": ctx.name,
2385
+ "error.type": ctx.error.name,
2386
+ "error.message": ctx.error.message
2387
+ });
2388
+ }));
2389
+ unregisters.push(hooks.hook("spawn:before", (ctx) => {
2390
+ const span = safeStartSpan(`handoff to ${ctx.task.length > 60 ? `${ctx.task.slice(0, 57)}…` : ctx.task}`, {
2391
+ [GEN_AI.operationName]: "handoff",
2392
+ [GEN_AI.system]: activeSystem,
2393
+ "sentry.op": "gen_ai.handoff",
2394
+ "gen_ai.agent.spawn.id": ctx.id,
2395
+ "gen_ai.agent.spawn.task": ctx.task,
2396
+ "gen_ai.agent.depth": ctx.depth
2397
+ });
2398
+ if (span) spawnSpans.set(ctx.id, span);
2399
+ if (typeof options.getActiveTraceContext === "function" && ctx.tracingContext) {
2400
+ let carrier;
2401
+ try {
2402
+ carrier = options.getActiveTraceContext();
2403
+ } catch (err) {
2404
+ try {
2405
+ onError("getActiveTraceContext", err);
2406
+ } catch {}
2407
+ }
2408
+ if (carrier) for (const [k, v] of Object.entries(carrier)) ctx.tracingContext[k] = v;
2409
+ }
2410
+ }));
2411
+ unregisters.push(hooks.hook("spawn:complete", (ctx) => {
2412
+ const span = spawnSpans.get(ctx.id);
2413
+ const childUsage = buildUsageAttrs({
2414
+ input: ctx.stats.totalIn,
2415
+ output: ctx.stats.totalOut,
2416
+ cacheRead: ctx.stats.totalCacheRead,
2417
+ cacheCreation: ctx.stats.totalCacheCreation,
2418
+ cost: ctx.stats.cost
2419
+ }, conventions);
2420
+ safeSetAttrs(span, {
2421
+ "gen_ai.agent.spawn.status": ctx.status ?? "completed",
2422
+ "gen_ai.agent.spawn.depth": ctx.depth,
2423
+ ...childUsage
2424
+ });
2425
+ safeEnd(spawnSpans, ctx.id);
2426
+ }));
2427
+ unregisters.push(hooks.hook("spawn:error", (ctx) => {
2428
+ safeSetAttrs(spawnSpans.get(ctx.id), {
2429
+ "error.type": ctx.error.name,
2430
+ "error.message": ctx.error.message
2431
+ });
2432
+ safeEnd(spawnSpans, ctx.id);
2433
+ }));
2434
+ let disposed = false;
2435
+ return function uninstall() {
2436
+ if (disposed) return;
2437
+ disposed = true;
2438
+ for (const un of unregisters) try {
2439
+ un();
2440
+ } catch {}
2441
+ endAll(runSpans);
2442
+ endAll(turnSpans);
2443
+ endAll(toolSpans);
2444
+ endAll(mcpSpans);
2445
+ endAll(spawnSpans);
2446
+ endAll(bootstrapSpans);
2447
+ activeRunSpan = void 0;
2448
+ };
2449
+ } };
2450
+ }
2451
+ /**
2452
+ * OpenTelemetry Gen AI semantic-convention attribute keys used by the
2453
+ * built-in tracer. Exported so consumers integrating with raw OTel SDKs
2454
+ * (instead of going through `createTracingHooks`) can stay aligned with
2455
+ * the names the harness itself emits.
2456
+ */
2457
+ const GEN_AI_ATTRIBUTES = GEN_AI;
2458
+ //#endregion
2459
+ export { zodToJsonSchema as A, tokenEndingAtCursor as C, shouldAutoGenerateTitle as D, generateSessionTitle as E, parseCommandInvocation as F, localReliabilityBehavior as I, commandBlocksToContent as M, commandSteps as N, isStandardSchema as O, executeCommand as P, resolveInlineSuggestion as S, cleanTitle as T, resolveExtensionDocsDir as _, loginMcpServer as a, createPathAutocompleteProvider as b, SKILL_EDIT_METADATA_KEY as c, buildZidaneExtensionSkill as d, buildZidaneSkillEditSkill as f, readSkillEditMarker as g, extensionScaffold as h, createMetricsHooks as i, commandBlockToEvent as j, resolveSchemaInput as k, ZIDANE_EXTENSION_SKILL_NAME as l, extensionDirForCwd as m, createTracingHooks as n, startOAuthCallback as o, editSessionMetadata as p, createMutationValidationHooks as r, EXTENSION_EDIT_METADATA_KEY as s, GEN_AI_ATTRIBUTES as t, ZIDANE_SKILL_EDIT_SKILL_NAME as u, withZidaneExtensionSkill as v, generateStructuredData as w, patchInlineGhost as x, withZidaneSkillEditSkill as y };
2460
+
2461
+ //# sourceMappingURL=src-DzzhE2YO.js.map