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.
- package/package.json +11 -11
- 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.
|
|
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.
|
|
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.
|
|
41
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
42
|
-
"@earendil-works/pi-tui": "^0.
|
|
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.
|
|
46
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
47
|
-
"@earendil-works/pi-tui": "^0.
|
|
48
|
-
"@types/node": "^
|
|
49
|
-
"tsx": "^4.
|
|
50
|
-
"typescript": "^
|
|
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 =
|
|
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)
|
|
552
|
-
// sharedSession before the next syncSharedSession call.
|
|
553
|
-
//
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
1134
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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;
|