pi-codebuddy-sdk 0.4.5 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codebuddy-sdk",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "Pi extension that uses CodeBuddy (via Agent SDK) as a model provider and adds an AskCodebuddy tool.",
5
5
  "author": "slahser",
6
6
  "license": "MIT",
@@ -32,9 +32,9 @@
32
32
  "typecheck": "tsc --noEmit"
33
33
  },
34
34
  "dependencies": {
35
- "@tencent-ai/agent-sdk": "^0.3.0",
35
+ "@tencent-ai/agent-sdk": "^0.3.259",
36
36
  "change-case": "^5.4.4",
37
- "typebox": "^1.3.0"
37
+ "typebox": "^1.3.33"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@earendil-works/pi-ai": "^0.85.1",
@@ -45,9 +45,9 @@
45
45
  "@earendil-works/pi-ai": "^0.85.1",
46
46
  "@earendil-works/pi-coding-agent": "^0.85.1",
47
47
  "@earendil-works/pi-tui": "^0.85.1",
48
- "@types/node": "^24.13.2",
49
- "tsx": "^4.22.4",
50
- "typescript": "^6.0.3"
48
+ "@types/node": "^26.6.1",
49
+ "tsx": "^4.23.13",
50
+ "typescript": "^7.0.2"
51
51
  },
52
52
  "pi": {
53
53
  "extensions": [
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import { dirname, join } from "path";
11
11
  import { PROVIDER_ID, messageContentToText, convertPiMessages } from "./convert.js";
12
12
  import { buildModels, codebuddyModelId, FALLBACK_MODELS, rawModelsFromSdk, resolveModel as _resolveModel, type PiModel } from "./models.js";
13
13
  import { readModelsCache, writeModelsCache } from "./models-cache.js";
14
+ import { readTranscript } from "./transcript-compat.js";
14
15
  import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, buildCodebuddySystemPrompt } from "./skills.js";
15
16
  import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
16
17
  import { extractAllToolResults as _extractAllToolResults, type McpResult } from "./extract-tool-results.js";
@@ -100,6 +101,76 @@ function diagDump(label: string, data: Record<string, unknown>) {
100
101
  debug(`DIAG: ${label} (see ${redactForLog(DIAG_LOG_PATH)})`);
101
102
  }
102
103
 
104
+ // --- SDK transport teardown guard (issue #10) ---
105
+ //
106
+ // @tencent-ai/agent-sdk dispatches late `control_request`/`mcp_message` frames
107
+ // fire-and-forget: `handleLine()` calls `this.handleMcpMessageRequest(req)`
108
+ // without awaiting or catching it. When the CLI subprocess has just been torn
109
+ // down (interrupt → retry), the response path throws
110
+ // `Error: Transport not started` from `writeLine()` inside an *unowned* async
111
+ // function → unhandledRejection → the host escalates it to `fatal` and exits.
112
+ //
113
+ // Upstream still ships the unguarded call as of 0.3.259, so we patch the
114
+ // transport prototype once per process: errors from a half-dead transport are
115
+ // undeliverable anyway and must be swallowed, not crash the session.
116
+ const patchedTransportPrototypes = new WeakSet<object>();
117
+
118
+ function noteTransportTeardown(where: string, err: unknown): void {
119
+ debug(`transport guard: swallowed ${where} failure during teardown: ${err instanceof Error ? err.message : String(err)}`);
120
+ diagDump("transport_teardown_swallowed", {
121
+ where,
122
+ message: err instanceof Error ? err.message : String(err),
123
+ stack: err instanceof Error ? err.stack?.split("\n").slice(0, 6).join(" | ") ?? null : null,
124
+ });
125
+ }
126
+
127
+ function installTransportTeardownGuard(sdkQuery: unknown): void {
128
+ const transport = (sdkQuery as { transport?: unknown } | null | undefined)?.transport;
129
+ if (!transport || typeof transport !== "object") return;
130
+ const proto = Object.getPrototypeOf(transport) as Record<string, unknown> | null;
131
+ if (!proto || patchedTransportPrototypes.has(proto)) return;
132
+
133
+ const originalHandle = proto.handleMcpMessageRequest;
134
+ if (typeof originalHandle === "function") {
135
+ proto.handleMcpMessageRequest = function (this: unknown, request: unknown) {
136
+ try {
137
+ return Promise.resolve(originalHandle.call(this, request)).catch((err: unknown) => {
138
+ noteTransportTeardown("handleMcpMessageRequest", err);
139
+ });
140
+ } catch (err) {
141
+ // Synchronous throw before a promise exists — same teardown class.
142
+ noteTransportTeardown("handleMcpMessageRequest(sync)", err);
143
+ return undefined;
144
+ }
145
+ };
146
+ }
147
+
148
+ const originalSendError = proto.sendControlErrorResponse;
149
+ if (typeof originalSendError === "function") {
150
+ proto.sendControlErrorResponse = function (this: unknown, requestId: unknown, error: unknown) {
151
+ try {
152
+ return (originalSendError as (...a: unknown[]) => unknown).call(this, requestId, error);
153
+ } catch (err) {
154
+ noteTransportTeardown("sendControlErrorResponse", err);
155
+ }
156
+ };
157
+ }
158
+
159
+ patchedTransportPrototypes.add(proto);
160
+ debug("transport guard: installed on SDK ProcessTransport prototype");
161
+ }
162
+
163
+ /** Create an SDK query and immediately arm the transport teardown guard. */
164
+ function startQuery(args: Parameters<typeof query>[0]): ReturnType<typeof query> {
165
+ const q = query(args);
166
+ try {
167
+ installTransportTeardownGuard(q);
168
+ } catch (err) {
169
+ debug(`transport guard: install failed: ${err instanceof Error ? err.message : String(err)}`);
170
+ }
171
+ return q;
172
+ }
173
+
103
174
  // --- Constants ---
104
175
 
105
176
  // Global key to prevent re-registration of the provider across module reloads.
@@ -343,13 +414,14 @@ async function runIsolatedSummary(
343
414
  };
344
415
 
345
416
  try {
346
- const promptText = extractIsolatedSummaryPrompt(context.messages);
417
+ const view = readTranscript(context);
418
+ const promptText = extractIsolatedSummaryPrompt(view.messages);
347
419
  const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
348
420
  const codebuddyExecutable = loadConfig(cwd).provider?.pathToCodebuddyCode;
349
421
  const cliModel = codebuddyModelId(model);
350
422
  debug(`compact summary: spawn model=${cliModel} registeredModel=${model.id} promptLen=${promptText.length}`);
351
423
 
352
- sdkQuery = query({
424
+ sdkQuery = startQuery({
353
425
  prompt: promptText,
354
426
  options: {
355
427
  cwd,
@@ -358,7 +430,7 @@ async function runIsolatedSummary(
358
430
  strictMcpConfig: true,
359
431
  settingSources: [] as SettingSource[],
360
432
  persistSession: false,
361
- systemPrompt: context.systemPrompt,
433
+ systemPrompt: view.systemPrompt,
362
434
  model: cliModel,
363
435
  maxTurns: 1,
364
436
  ...(codebuddyExecutable ? { pathToCodebuddyCode: codebuddyExecutable } : {}),
@@ -524,6 +596,7 @@ function syncSharedSession(
524
596
  cwd: string,
525
597
  customToolNameToSdk?: Map<string, string>,
526
598
  modelId?: string,
599
+ isReentrant?: boolean,
527
600
  ): SyncResult {
528
601
  const priorMessages = messages.slice(0, -1); // everything before the new user prompt
529
602
 
@@ -548,13 +621,37 @@ function syncSharedSession(
548
621
  }
549
622
  }
550
623
  // Only reachable when needsRebuild is false — user-facing history rewrites
551
- // (/compact, session_tree, /new, fork) always set needsRebuild or clear
552
- // sharedSession before the next syncSharedSession call. In practice this
553
- // fires only for isolated compact-summary subprocesses.
624
+ // (/compact, session_tree, /new, fork) usually set needsRebuild or clear
625
+ // sharedSession before the next syncSharedSession call. The remaining case is
626
+ // a pi-side history rewrite that did NOT flag us: a branch summary after an
627
+ // interrupted turn (a retry whose abort never reached the provider), which
628
+ // makes pi's visible history shorter than our cursor.
554
629
  if (sharedSession && !sharedSession.needsRebuild && priorMessages.length < sharedSession.cursor) {
555
- debug(`Case 1 synthetic: clean start for shorter context, preserving shared session ${sharedSession.sessionId.slice(0, 8)}, cursor=${sharedSession.cursor}`);
556
- debug(`syncResult: path=clean-start preserve-shared sessionId=${sharedSession.sessionId} cursor=${sharedSession.cursor}`);
557
- return { sessionId: null, preserveSharedSession: true };
630
+ // Top-level call with real history left: falling through to a clean start
631
+ // would send only the final user message with no `resume`, so the model
632
+ // answers as if the conversation never happened (issue #10). Rewrite the
633
+ // shared session from the truncated history instead — the turn keeps
634
+ // whatever context pi still has. forceRotate avoids racing a CLI
635
+ // subprocess that may still be writing to the old session file.
636
+ if (priorMessages.length > 0 && !isReentrant) {
637
+ const dropped = sharedSession.cursor - priorMessages.length;
638
+ const sessionId = sharedSession.sessionId;
639
+ debug(`Case 1 synthetic: history shrank by ${dropped} (${priorMessages.length} < cursor ${sharedSession.cursor}) — rebuilding shared session ${sessionId.slice(0, 8)} instead of clean start`);
640
+ piUI?.notify(
641
+ `CodeBuddy SDK: conversation history shrank by ${dropped} message(s) — rebuilding session context instead of starting fresh (see ${ISSUES_URL}#issue-comments if the reply still lacks context)`,
642
+ "warning",
643
+ );
644
+ diagDump("history_shrank_rebuild", { dropped, priorMessages: priorMessages.length, cursor: sharedSession.cursor, sessionId });
645
+ sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
646
+ // fall through to REBUILD below
647
+ } else {
648
+ // No prior history (isolated compact-summary prompt) or a reentrant
649
+ // subagent call: nothing to rebuild from, or rewriting would clobber
650
+ // the parent's shared session. Keep the ephemeral clean start.
651
+ debug(`Case 1 synthetic: clean start for shorter context (prior=${priorMessages.length}, reentrant=${Boolean(isReentrant)}), preserving shared session ${sharedSession.sessionId.slice(0, 8)}, cursor=${sharedSession.cursor}`);
652
+ debug(`syncResult: path=clean-start preserve-shared sessionId=${sharedSession.sessionId} cursor=${sharedSession.cursor}`);
653
+ return { sessionId: null, preserveSharedSession: true };
654
+ }
558
655
  }
559
656
 
560
657
  // REBUILD path
@@ -677,7 +774,7 @@ function contextForToolResults(results: McpResult[]): QueryContext | undefined {
677
774
  return undefined;
678
775
  }
679
776
 
680
- function resolveMcpTools(context: Context, excludeToolName?: string): {
777
+ function resolveMcpTools(declaredTools: Tool[] | undefined, excludeToolName?: string): {
681
778
  mcpTools: Tool[];
682
779
  customToolNameToSdk: Map<string, string>;
683
780
  customToolNameToPi: Map<string, string>;
@@ -686,9 +783,9 @@ function resolveMcpTools(context: Context, excludeToolName?: string): {
686
783
  const customToolNameToSdk = new Map<string, string>();
687
784
  const customToolNameToPi = new Map<string, string>();
688
785
 
689
- if (!context.tools) return { mcpTools, customToolNameToSdk, customToolNameToPi };
786
+ if (!declaredTools) return { mcpTools, customToolNameToSdk, customToolNameToPi };
690
787
 
691
- for (const tool of context.tools) {
788
+ for (const tool of declaredTools) {
692
789
  if (tool.name === excludeToolName) continue;
693
790
  const sdkName = `${MCP_TOOL_PREFIX}${tool.name}`;
694
791
  mcpTools.push(tool);
@@ -1092,8 +1189,12 @@ async function consumeQuery(
1092
1189
  function streamCodebuddySdk(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
1093
1190
  const stream = newAssistantMessageEventStream();
1094
1191
 
1192
+ // Upstream #9548: pi-ai >= 0.85.2 passes a normalized transcript (prompt + tools carried by
1193
+ // system messages). Read both shapes so this provider works before and after that change.
1194
+ const { messages: convoMessages, systemPrompt: baseSystemPrompt, tools: declaredTools } = readTranscript(context);
1195
+
1095
1196
  // DEBUG: trace followUp message triggering
1096
- const lastMsgRole = context.messages[context.messages.length - 1]?.role;
1197
+ const lastMsgRole = convoMessages[convoMessages.length - 1]?.role;
1097
1198
  debug(`provider: streamCodebuddySdk called, activeQuery=${!!ctx().activeQuery}, lastMsgRole=${lastMsgRole}, isReentrant=${ctx().activeQuery !== null}`);
1098
1199
 
1099
1200
  const activeQuery = ctx().activeQuery !== null;
@@ -1101,7 +1202,7 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1101
1202
  const resultCtx = allResults.length > 0 ? contextForToolResults(allResults) : undefined;
1102
1203
  const isReentrantUserQuery = activeQuery && lastMsgRole === "user" && allResults.length === 0;
1103
1204
  if (isReentrantUserQuery) {
1104
- debug(`provider: active query user-only call treated as reentrant fresh query, waitingHandlers=${ctx().pendingToolCalls.size}, ctx.msgs=${context.messages.length}`);
1205
+ debug(`provider: active query user-only call treated as reentrant fresh query, waitingHandlers=${ctx().pendingToolCalls.size}, ctx.msgs=${convoMessages.length}`);
1105
1206
  }
1106
1207
 
1107
1208
  // --- Tool result delivery ---
@@ -1111,7 +1212,7 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1111
1212
  if (resultCtx) {
1112
1213
  claimCurrentPiStream(stream, "tool-result", resultCtx);
1113
1214
  resultCtx.resetTurnState(model);
1114
- debug(`provider: tool results, ${allResults.length} results, ${resultCtx.pendingToolCalls.size} waiting handlers, ctx.msgs=${context.messages.length}`);
1215
+ debug(`provider: tool results, ${allResults.length} results, ${resultCtx.pendingToolCalls.size} waiting handlers, ctx.msgs=${convoMessages.length}`);
1115
1216
  for (const result of allResults) {
1116
1217
  const id = result.toolCallId;
1117
1218
  if (id && resultCtx.pendingToolCalls.has(id)) {
@@ -1130,8 +1231,15 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1130
1231
  }
1131
1232
  }
1132
1233
  if (resultCtx.pendingToolCalls.size > 0) {
1133
- debug(`WARNING: ${resultCtx.pendingToolCalls.size} MCP handlers still waiting after delivering ${allResults.length} results`);
1134
- piUI?.notify(`CodeBuddy SDK: ${resultCtx.pendingToolCalls.size} tool handler(s) still waiting — provider may be stuck`, "warning");
1234
+ debug(`WARNING: ${resultCtx.pendingToolCalls.size} MCP handlers still waiting after delivering ${allResults.length} results`);
1235
+ // The wedged turn cannot be unwedged in place: retrying tears down the
1236
+ // CLI subprocess while its control pipe is still alive (issue #10). Exit
1237
+ // and resume the session instead — the local transcript is intact.
1238
+ piUI?.notify(
1239
+ `CodeBuddy SDK: ${resultCtx.pendingToolCalls.size} tool handler(s) still waiting — provider may be stuck. ` +
1240
+ `Prefer exiting and resuming this session over retrying in place, or the reply may lose context.`,
1241
+ "warning",
1242
+ );
1135
1243
  }
1136
1244
 
1137
1245
  // Detect user messages (steer/followUp) that pi injected into context
@@ -1143,25 +1251,25 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1143
1251
  // The bridge can't forward these mid-query (the SDK query is in progress),
1144
1252
  // so we save them for replay as continuation queries after consumeQuery ends.
1145
1253
  if (lastMsgRole === "user") {
1146
- const userPrompt = extractUserPrompt(context.messages);
1254
+ const userPrompt = extractUserPrompt(convoMessages);
1147
1255
  if (userPrompt) {
1148
1256
  resultCtx.deferredUserMessages.push(userPrompt);
1149
1257
  debug(`provider: deferred user message for replay after query (len=${userPrompt.length})`);
1150
1258
  }
1151
1259
  }
1152
1260
 
1153
- if (sharedSession) sharedSession.cursor = context.messages.length;
1154
- resultCtx.latestCursor = Math.max(resultCtx.latestCursor, context.messages.length);
1261
+ if (sharedSession) sharedSession.cursor = convoMessages.length;
1262
+ resultCtx.latestCursor = Math.max(resultCtx.latestCursor, convoMessages.length);
1155
1263
  return stream;
1156
1264
  }
1157
1265
 
1158
1266
  // --- Orphaned tool result (e.g. user aborted a tool call) ---
1159
1267
  // The query is gone but pi still delivered the result. Nothing to do — just
1160
1268
  // emit end_turn so pi waits for the next real user message.
1161
- const lastMsg = context.messages[context.messages.length - 1];
1269
+ const lastMsg = convoMessages[convoMessages.length - 1];
1162
1270
  if (lastMsg?.role === "toolResult") {
1163
1271
  debug(`provider: orphaned tool result after abort, emitting end_turn`);
1164
- if (sharedSession) sharedSession.cursor = context.messages.length;
1272
+ if (sharedSession) sharedSession.cursor = convoMessages.length;
1165
1273
  const c = ctx(); // capture current context for the microtask
1166
1274
  queueMicrotask(() => {
1167
1275
  c.resetTurnState(model);
@@ -1189,24 +1297,24 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1189
1297
  queryCtx.resetTurnState(model);
1190
1298
  queryCtx.latestCursor = 0;
1191
1299
 
1192
- const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context, askCodebuddyToolName);
1300
+ const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(declaredTools, askCodebuddyToolName);
1193
1301
  const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
1194
- const syncResult = syncSharedSession(context.messages, cwd, customToolNameToSdk, model.id);
1302
+ const syncResult = syncSharedSession(convoMessages, cwd, customToolNameToSdk, model.id, Boolean(isReentrant));
1195
1303
  const { sessionId: resumeSessionId } = syncResult;
1196
- const promptBlocks = extractUserPromptBlocks(context.messages);
1197
- let promptText = extractUserPrompt(context.messages) ?? "";
1304
+ const promptBlocks = extractUserPromptBlocks(convoMessages);
1305
+ let promptText = extractUserPrompt(convoMessages) ?? "";
1198
1306
 
1199
1307
  // Guard: empty prompt means the last context message isn't a user message.
1200
1308
  // This should never happen with per-query state — dump diagnostics if it does.
1201
1309
  if (!promptText && !promptBlocks) {
1202
1310
  diagDump("empty_prompt", {
1203
- contextLength: context.messages.length,
1311
+ contextLength: convoMessages.length,
1204
1312
  lastMsgRole: lastMsg?.role,
1205
1313
  isReentrant,
1206
1314
  activeQueryContexts: activeQueryContexts.size,
1207
1315
  activeQueryExists: queryCtx.activeQuery !== null,
1208
1316
  sharedSession: sharedSession ? { sessionId: sharedSession.sessionId.slice(0, 8), cursor: sharedSession.cursor } : null,
1209
- messageRoles: context.messages.map((m, i) => `[${i}]${m.role}`).join(" "),
1317
+ messageRoles: convoMessages.map((m, i) => `[${i}]${m.role}`).join(" "),
1210
1318
  });
1211
1319
  // Recover: use a continuation prompt so the SDK doesn't send an empty text block
1212
1320
  promptText = "[continue]";
@@ -1218,7 +1326,7 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1218
1326
  const mcpServers = buildMcpServers(mcpTools, queryCtx);
1219
1327
  const appendSystemPrompt = providerSettings.appendSystemPrompt !== false;
1220
1328
  const systemPrompt = appendSystemPrompt
1221
- ? buildCodebuddySystemPrompt(context.systemPrompt)
1329
+ ? buildCodebuddySystemPrompt(baseSystemPrompt)
1222
1330
  : undefined;
1223
1331
 
1224
1332
  // MCP auto-loading suppression: CC reads MCP servers from ~/.claude.json (top-level
@@ -1264,7 +1372,7 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1264
1372
  };
1265
1373
 
1266
1374
  debug("provider: fresh query",
1267
- `model=${cliModel} msgs=${context.messages.length} tools=${mcpTools.length}`,
1375
+ `model=${cliModel} msgs=${convoMessages.length} tools=${mcpTools.length}`,
1268
1376
  `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
1269
1377
  `appendSys=${appendSystemPrompt} strictMcp=${strictMcpConfigEnabled}`,
1270
1378
  `promptLen=${promptText.length}${promptBlocks ? " [+images]" : ""}`);
@@ -1292,7 +1400,7 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1292
1400
 
1293
1401
  void (async () => {
1294
1402
  await ensureModelsDiscovered();
1295
- sdkQuery = query({ prompt, options: queryOptions });
1403
+ sdkQuery = startQuery({ prompt, options: queryOptions });
1296
1404
  queryCtx.activeQuery = sdkQuery;
1297
1405
  activeQueryContexts.add(queryCtx);
1298
1406
 
@@ -1324,7 +1432,7 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1324
1432
  }
1325
1433
  debug(`provider: query done, ignoring captured session ${capturedSessionId?.slice(0, 8) ?? "none"} to preserve shared session`);
1326
1434
  } else if (sessionId) {
1327
- const cursor = Math.max(context.messages.length, queryCtx.latestCursor, sharedSession?.cursor ?? 0);
1435
+ const cursor = Math.max(convoMessages.length, queryCtx.latestCursor, sharedSession?.cursor ?? 0);
1328
1436
  debug(`provider: query done, session=${sessionId.slice(0, 8)}, cursor=${cursor}`);
1329
1437
  sharedSession = { sessionId, cursor, cwd };
1330
1438
  }
@@ -1341,7 +1449,7 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1341
1449
  }
1342
1450
 
1343
1451
  const contOptions = { ...queryOptions, resume: resumeId, ...makeCliDebugOptions("continuation") };
1344
- const contQuery = query({ prompt: steerPrompt, options: contOptions });
1452
+ const contQuery = startQuery({ prompt: steerPrompt, options: contOptions });
1345
1453
  queryCtx.activeQuery = contQuery;
1346
1454
  debug(`provider: continuation query, model=${cliModel}, resume=${resumeId.slice(0, 8)}, promptLen=${steerPrompt.length}`);
1347
1455
 
@@ -1468,7 +1576,7 @@ async function promptAndWait(
1468
1576
  `isolated=${options?.isolated ?? false} resume=${resumeSessionId?.slice(0, 8) ?? "none"}`,
1469
1577
  `sysPrompt=${Boolean(askSystemPrompt)} promptLen=${prompt.length}`);
1470
1578
 
1471
- const sdkQuery = query({
1579
+ const sdkQuery = startQuery({
1472
1580
  prompt,
1473
1581
  options: {
1474
1582
  cwd,
@@ -1580,7 +1688,7 @@ let discoverInFlight: Promise<void> | null = null;
1580
1688
  async function discoverModels(pi: ExtensionAPI): Promise<void> {
1581
1689
  await withSdkGate(async () => {
1582
1690
  try {
1583
- const q = query({ prompt: " ", options: { maxTurns: 0, permissionMode: "bypassPermissions", tools: [] } });
1691
+ const q = startQuery({ prompt: " ", options: { maxTurns: 0, permissionMode: "bypassPermissions", tools: [] } });
1584
1692
  const supported = await q.supportedModels();
1585
1693
  await q.return().catch(() => {});
1586
1694
  if (!supported.length) return;
@@ -0,0 +1,81 @@
1
+ import * as piAi from "@earendil-works/pi-ai";
2
+ import type { Message, Tool } from "@earendil-works/pi-ai";
3
+
4
+ // Upstream #9548 ("Mid conversation system messages") folds `Context.systemPrompt` and
5
+ // `Context.tools` into the transcript's system messages, so provider-facing code receives a
6
+ // `TranscriptContext` that only carries `messages`. This module reads both shapes so the
7
+ // provider keeps working on pi-ai <= 0.85.1 and >= 0.85.2 without a hard version bump.
8
+
9
+ export interface TranscriptView {
10
+ /** Conversation messages with system messages removed (the pre-#9548 shape). */
11
+ messages: Message[];
12
+ /** Rendered system prompt, or undefined when the request carries none. */
13
+ systemPrompt: string | undefined;
14
+ /** Tool declarations available for this request. */
15
+ tools: Tool[] | undefined;
16
+ }
17
+
18
+ type MaybeTranscript = {
19
+ messages: Message[];
20
+ systemPrompt?: string;
21
+ tools?: Tool[];
22
+ };
23
+
24
+ const ai = piAi as unknown as Record<string, unknown>;
25
+
26
+ function textOfContent(content: unknown): string {
27
+ if (typeof content === "string") return content;
28
+ if (Array.isArray(content)) {
29
+ return content
30
+ .map((part) =>
31
+ part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string"
32
+ ? (part as { text: string }).text
33
+ : "",
34
+ )
35
+ .join("");
36
+ }
37
+ return "";
38
+ }
39
+
40
+ /**
41
+ * Read the system prompt, tool declarations, and conversation from a request context that
42
+ * may be either a legacy `Context` or a normalized `TranscriptContext`.
43
+ */
44
+ export function readTranscript(context: MaybeTranscript): TranscriptView {
45
+ const messages = Array.isArray(context.messages) ? context.messages : [];
46
+
47
+ // Legacy shape (pi-ai <= 0.85.1): prompt and tools are top-level fields.
48
+ if (typeof context.systemPrompt === "string" || Array.isArray(context.tools)) {
49
+ return { messages, systemPrompt: context.systemPrompt, tools: context.tools };
50
+ }
51
+
52
+ // Normalized shape (pi-ai >= 0.85.2): replay the transcript through upstream helpers.
53
+ const collapseSystemMessages = ai.collapseSystemMessages as
54
+ | ((c: { messages: Message[] }) => { messages: Message[] })
55
+ | undefined;
56
+ const getCurrentSystemPrompt = ai.getCurrentSystemPrompt as ((m: readonly unknown[]) => string) | undefined;
57
+ const getCurrentTools = ai.getCurrentTools as ((m: readonly unknown[]) => Tool[]) | undefined;
58
+ if (
59
+ typeof collapseSystemMessages === "function" &&
60
+ typeof getCurrentSystemPrompt === "function" &&
61
+ typeof getCurrentTools === "function"
62
+ ) {
63
+ const collapsed = collapseSystemMessages({ messages });
64
+ return {
65
+ messages: collapsed.messages.filter((message) => (message as { role?: string }).role !== "system"),
66
+ systemPrompt: getCurrentSystemPrompt(collapsed.messages) || undefined,
67
+ tools: getCurrentTools(collapsed.messages),
68
+ };
69
+ }
70
+
71
+ // No upstream helpers available: best-effort replay of the leading system message.
72
+ const systemMessages = messages.filter((message) => (message as { role?: string }).role === "system");
73
+ const conversation = messages.filter((message) => (message as { role?: string }).role !== "system");
74
+ const head = systemMessages[0] as (Message & { content?: unknown; toolsAdded?: Tool[] }) | undefined;
75
+ if (!head) return { messages: conversation, systemPrompt: undefined, tools: undefined };
76
+ return {
77
+ messages: conversation,
78
+ systemPrompt: textOfContent(head.content) || undefined,
79
+ tools: head.toolsAdded,
80
+ };
81
+ }