pi-codebuddy-sdk 0.4.4 → 0.4.6

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 (2) hide show
  1. package/package.json +11 -11
  2. package/src/index.ts +116 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codebuddy-sdk",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
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,22 +32,22 @@
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
37
  "typebox": "^1.3.0"
38
38
  },
39
39
  "peerDependencies": {
40
- "@earendil-works/pi-ai": "^0.84.2",
41
- "@earendil-works/pi-coding-agent": "^0.84.2",
42
- "@earendil-works/pi-tui": "^0.84.2"
40
+ "@earendil-works/pi-ai": "^0.85.1",
41
+ "@earendil-works/pi-coding-agent": "^0.85.1",
42
+ "@earendil-works/pi-tui": "^0.85.1"
43
43
  },
44
44
  "devDependencies": {
45
- "@earendil-works/pi-ai": "^0.84.2",
46
- "@earendil-works/pi-coding-agent": "^0.84.2",
47
- "@earendil-works/pi-tui": "^0.84.2",
48
- "@types/node": "^24.13.2",
49
- "tsx": "^4.22.4",
50
- "typescript": "^6.0.3"
45
+ "@earendil-works/pi-ai": "^0.85.1",
46
+ "@earendil-works/pi-coding-agent": "^0.85.1",
47
+ "@earendil-works/pi-tui": "^0.85.1",
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
@@ -100,6 +100,76 @@ function diagDump(label: string, data: Record<string, unknown>) {
100
100
  debug(`DIAG: ${label} (see ${redactForLog(DIAG_LOG_PATH)})`);
101
101
  }
102
102
 
103
+ // --- SDK transport teardown guard (issue #10) ---
104
+ //
105
+ // @tencent-ai/agent-sdk dispatches late `control_request`/`mcp_message` frames
106
+ // fire-and-forget: `handleLine()` calls `this.handleMcpMessageRequest(req)`
107
+ // without awaiting or catching it. When the CLI subprocess has just been torn
108
+ // down (interrupt → retry), the response path throws
109
+ // `Error: Transport not started` from `writeLine()` inside an *unowned* async
110
+ // function → unhandledRejection → the host escalates it to `fatal` and exits.
111
+ //
112
+ // Upstream still ships the unguarded call as of 0.3.259, so we patch the
113
+ // transport prototype once per process: errors from a half-dead transport are
114
+ // undeliverable anyway and must be swallowed, not crash the session.
115
+ const patchedTransportPrototypes = new WeakSet<object>();
116
+
117
+ function noteTransportTeardown(where: string, err: unknown): void {
118
+ debug(`transport guard: swallowed ${where} failure during teardown: ${err instanceof Error ? err.message : String(err)}`);
119
+ diagDump("transport_teardown_swallowed", {
120
+ where,
121
+ message: err instanceof Error ? err.message : String(err),
122
+ stack: err instanceof Error ? err.stack?.split("\n").slice(0, 6).join(" | ") ?? null : null,
123
+ });
124
+ }
125
+
126
+ function installTransportTeardownGuard(sdkQuery: unknown): void {
127
+ const transport = (sdkQuery as { transport?: unknown } | null | undefined)?.transport;
128
+ if (!transport || typeof transport !== "object") return;
129
+ const proto = Object.getPrototypeOf(transport) as Record<string, unknown> | null;
130
+ if (!proto || patchedTransportPrototypes.has(proto)) return;
131
+
132
+ const originalHandle = proto.handleMcpMessageRequest;
133
+ if (typeof originalHandle === "function") {
134
+ proto.handleMcpMessageRequest = function (this: unknown, request: unknown) {
135
+ try {
136
+ return Promise.resolve(originalHandle.call(this, request)).catch((err: unknown) => {
137
+ noteTransportTeardown("handleMcpMessageRequest", err);
138
+ });
139
+ } catch (err) {
140
+ // Synchronous throw before a promise exists — same teardown class.
141
+ noteTransportTeardown("handleMcpMessageRequest(sync)", err);
142
+ return undefined;
143
+ }
144
+ };
145
+ }
146
+
147
+ const originalSendError = proto.sendControlErrorResponse;
148
+ if (typeof originalSendError === "function") {
149
+ proto.sendControlErrorResponse = function (this: unknown, requestId: unknown, error: unknown) {
150
+ try {
151
+ return (originalSendError as (...a: unknown[]) => unknown).call(this, requestId, error);
152
+ } catch (err) {
153
+ noteTransportTeardown("sendControlErrorResponse", err);
154
+ }
155
+ };
156
+ }
157
+
158
+ patchedTransportPrototypes.add(proto);
159
+ debug("transport guard: installed on SDK ProcessTransport prototype");
160
+ }
161
+
162
+ /** Create an SDK query and immediately arm the transport teardown guard. */
163
+ function startQuery(args: Parameters<typeof query>[0]): ReturnType<typeof query> {
164
+ const q = query(args);
165
+ try {
166
+ installTransportTeardownGuard(q);
167
+ } catch (err) {
168
+ debug(`transport guard: install failed: ${err instanceof Error ? err.message : String(err)}`);
169
+ }
170
+ return q;
171
+ }
172
+
103
173
  // --- Constants ---
104
174
 
105
175
  // Global key to prevent re-registration of the provider across module reloads.
@@ -349,7 +419,7 @@ async function runIsolatedSummary(
349
419
  const cliModel = codebuddyModelId(model);
350
420
  debug(`compact summary: spawn model=${cliModel} registeredModel=${model.id} promptLen=${promptText.length}`);
351
421
 
352
- sdkQuery = query({
422
+ sdkQuery = startQuery({
353
423
  prompt: promptText,
354
424
  options: {
355
425
  cwd,
@@ -524,6 +594,7 @@ function syncSharedSession(
524
594
  cwd: string,
525
595
  customToolNameToSdk?: Map<string, string>,
526
596
  modelId?: string,
597
+ isReentrant?: boolean,
527
598
  ): SyncResult {
528
599
  const priorMessages = messages.slice(0, -1); // everything before the new user prompt
529
600
 
@@ -548,13 +619,37 @@ function syncSharedSession(
548
619
  }
549
620
  }
550
621
  // 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.
622
+ // (/compact, session_tree, /new, fork) usually set needsRebuild or clear
623
+ // sharedSession before the next syncSharedSession call. The remaining case is
624
+ // a pi-side history rewrite that did NOT flag us: a branch summary after an
625
+ // interrupted turn (a retry whose abort never reached the provider), which
626
+ // makes pi's visible history shorter than our cursor.
554
627
  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 };
628
+ // Top-level call with real history left: falling through to a clean start
629
+ // would send only the final user message with no `resume`, so the model
630
+ // answers as if the conversation never happened (issue #10). Rewrite the
631
+ // shared session from the truncated history instead — the turn keeps
632
+ // whatever context pi still has. forceRotate avoids racing a CLI
633
+ // subprocess that may still be writing to the old session file.
634
+ if (priorMessages.length > 0 && !isReentrant) {
635
+ const dropped = sharedSession.cursor - priorMessages.length;
636
+ const sessionId = sharedSession.sessionId;
637
+ debug(`Case 1 synthetic: history shrank by ${dropped} (${priorMessages.length} < cursor ${sharedSession.cursor}) — rebuilding shared session ${sessionId.slice(0, 8)} instead of clean start`);
638
+ piUI?.notify(
639
+ `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)`,
640
+ "warning",
641
+ );
642
+ diagDump("history_shrank_rebuild", { dropped, priorMessages: priorMessages.length, cursor: sharedSession.cursor, sessionId });
643
+ sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
644
+ // fall through to REBUILD below
645
+ } else {
646
+ // No prior history (isolated compact-summary prompt) or a reentrant
647
+ // subagent call: nothing to rebuild from, or rewriting would clobber
648
+ // the parent's shared session. Keep the ephemeral clean start.
649
+ 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}`);
650
+ debug(`syncResult: path=clean-start preserve-shared sessionId=${sharedSession.sessionId} cursor=${sharedSession.cursor}`);
651
+ return { sessionId: null, preserveSharedSession: true };
652
+ }
558
653
  }
559
654
 
560
655
  // REBUILD path
@@ -1130,8 +1225,15 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1130
1225
  }
1131
1226
  }
1132
1227
  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");
1228
+ debug(`WARNING: ${resultCtx.pendingToolCalls.size} MCP handlers still waiting after delivering ${allResults.length} results`);
1229
+ // The wedged turn cannot be unwedged in place: retrying tears down the
1230
+ // CLI subprocess while its control pipe is still alive (issue #10). Exit
1231
+ // and resume the session instead — the local transcript is intact.
1232
+ piUI?.notify(
1233
+ `CodeBuddy SDK: ${resultCtx.pendingToolCalls.size} tool handler(s) still waiting — provider may be stuck. ` +
1234
+ `Prefer exiting and resuming this session over retrying in place, or the reply may lose context.`,
1235
+ "warning",
1236
+ );
1135
1237
  }
1136
1238
 
1137
1239
  // Detect user messages (steer/followUp) that pi injected into context
@@ -1191,7 +1293,7 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1191
1293
 
1192
1294
  const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context, askCodebuddyToolName);
1193
1295
  const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
1194
- const syncResult = syncSharedSession(context.messages, cwd, customToolNameToSdk, model.id);
1296
+ const syncResult = syncSharedSession(context.messages, cwd, customToolNameToSdk, model.id, Boolean(isReentrant));
1195
1297
  const { sessionId: resumeSessionId } = syncResult;
1196
1298
  const promptBlocks = extractUserPromptBlocks(context.messages);
1197
1299
  let promptText = extractUserPrompt(context.messages) ?? "";
@@ -1292,7 +1394,7 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1292
1394
 
1293
1395
  void (async () => {
1294
1396
  await ensureModelsDiscovered();
1295
- sdkQuery = query({ prompt, options: queryOptions });
1397
+ sdkQuery = startQuery({ prompt, options: queryOptions });
1296
1398
  queryCtx.activeQuery = sdkQuery;
1297
1399
  activeQueryContexts.add(queryCtx);
1298
1400
 
@@ -1341,7 +1443,7 @@ function streamCodebuddySdk(model: Model<any>, context: Context, options?: Simpl
1341
1443
  }
1342
1444
 
1343
1445
  const contOptions = { ...queryOptions, resume: resumeId, ...makeCliDebugOptions("continuation") };
1344
- const contQuery = query({ prompt: steerPrompt, options: contOptions });
1446
+ const contQuery = startQuery({ prompt: steerPrompt, options: contOptions });
1345
1447
  queryCtx.activeQuery = contQuery;
1346
1448
  debug(`provider: continuation query, model=${cliModel}, resume=${resumeId.slice(0, 8)}, promptLen=${steerPrompt.length}`);
1347
1449
 
@@ -1468,7 +1570,7 @@ async function promptAndWait(
1468
1570
  `isolated=${options?.isolated ?? false} resume=${resumeSessionId?.slice(0, 8) ?? "none"}`,
1469
1571
  `sysPrompt=${Boolean(askSystemPrompt)} promptLen=${prompt.length}`);
1470
1572
 
1471
- const sdkQuery = query({
1573
+ const sdkQuery = startQuery({
1472
1574
  prompt,
1473
1575
  options: {
1474
1576
  cwd,
@@ -1580,7 +1682,7 @@ let discoverInFlight: Promise<void> | null = null;
1580
1682
  async function discoverModels(pi: ExtensionAPI): Promise<void> {
1581
1683
  await withSdkGate(async () => {
1582
1684
  try {
1583
- const q = query({ prompt: " ", options: { maxTurns: 0, permissionMode: "bypassPermissions", tools: [] } });
1685
+ const q = startQuery({ prompt: " ", options: { maxTurns: 0, permissionMode: "bypassPermissions", tools: [] } });
1584
1686
  const supported = await q.supportedModels();
1585
1687
  await q.return().catch(() => {});
1586
1688
  if (!supported.length) return;