@lincoln504/pi-research 1.6.10 → 1.6.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/agent-skill/pi-research/SKILL.md +1 -1
- package/dist/cli.mjs +74 -20
- package/dist/thread-worker.mjs +100 -22
- package/docs/CHANGELOG.md +41 -0
- package/docs/CONFIGURATION.md +7 -2
- package/docs/PI-EXTENSION.md +2 -1
- package/package.json +21 -19
- package/src/config.ts +10 -4
- package/src/core/pi-version.ts +2 -2
- package/src/core/planning-utils.ts +34 -2
- package/src/infrastructure/browser/config.ts +3 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +29 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +116 -19
- package/src/infrastructure/browser/thread-worker.mjs +100 -22
- package/src/infrastructure/browser/worker-pool-manager.ts +10 -1
- package/src/orchestration/deep-research-orchestrator.ts +12 -10
- package/src/sdk.ts +38 -11
- package/src/stackexchange/output/compact.ts +2 -1
- package/src/stackexchange/output/table.ts +17 -6
- package/src/tools/research-knowledge-search.ts +1 -1
- package/src/tools/stackexchange.ts +1 -0
- package/src/tui/tui-controller.ts +57 -15
- package/src/utils/plain-url.ts +44 -0
- package/src/web-research/retry-utils.ts +11 -1
|
@@ -104,13 +104,15 @@ export class DeepResearchOrchestrator {
|
|
|
104
104
|
const observer = this.observer;
|
|
105
105
|
const container = tryGetServiceContainerFromCtx(ctx);
|
|
106
106
|
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
107
|
+
// The initial coordinator/evaluator (round 1 planning) uses the
|
|
108
|
+
// parent chat's model (ctx.model) so the prompt-cache prefix is
|
|
109
|
+
// shared with the conversation that preceded the research tool call —
|
|
110
|
+
// same provider, same model, same cache lineage.
|
|
111
|
+
// Later evaluator rounds (round 2+ routing) and final synthesis
|
|
112
|
+
// use the research model (RESEARCH_MODEL override or host model),
|
|
113
|
+
// which is also what the researcher sub-agents use.
|
|
114
|
+
const initialCoordinatorModel = (ctx.model as Model<any> | undefined) ?? model;
|
|
115
|
+
const evaluatorModel = model;
|
|
114
116
|
|
|
115
117
|
const orchestrationService = await this.getOrchestrationService();
|
|
116
118
|
const planningService = await this.getPlanningService();
|
|
@@ -249,7 +251,7 @@ export class DeepResearchOrchestrator {
|
|
|
249
251
|
sessionId: researchId,
|
|
250
252
|
query,
|
|
251
253
|
complexity,
|
|
252
|
-
model:
|
|
254
|
+
model: initialCoordinatorModel,
|
|
253
255
|
modelRegistry: ctx.modelRegistry,
|
|
254
256
|
cwd: ctx.cwd,
|
|
255
257
|
config: this.config,
|
|
@@ -274,7 +276,7 @@ export class DeepResearchOrchestrator {
|
|
|
274
276
|
query: query,
|
|
275
277
|
complexity,
|
|
276
278
|
round: this.currentRound,
|
|
277
|
-
model:
|
|
279
|
+
model: evaluatorModel,
|
|
278
280
|
modelRegistry: ctx.modelRegistry,
|
|
279
281
|
cwd: ctx.cwd,
|
|
280
282
|
config: this.config,
|
|
@@ -568,7 +570,7 @@ export class DeepResearchOrchestrator {
|
|
|
568
570
|
query: query,
|
|
569
571
|
complexity,
|
|
570
572
|
round: this.currentRound,
|
|
571
|
-
model:
|
|
573
|
+
model: evaluatorModel,
|
|
572
574
|
modelRegistry: ctx.modelRegistry,
|
|
573
575
|
cwd: ctx.cwd,
|
|
574
576
|
config: this.config,
|
package/src/sdk.ts
CHANGED
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
registerOrchestrationServices,
|
|
19
19
|
initializeOrchestrationServices
|
|
20
20
|
} from './orchestration/service-initialization.ts';
|
|
21
|
-
import { getService, resetServiceContainer, getServiceContainer } from './core/service-registry.ts';
|
|
21
|
+
import { getService, resetServiceContainer, getServiceContainer, tryGetService } from './core/service-registry.ts';
|
|
22
|
+
import { raceWithDeadline } from './utils/safe-unref.ts';
|
|
22
23
|
import type { ServiceContainer } from './core/service-registry.ts';
|
|
23
24
|
import { ServiceNames } from './core/service-interfaces.ts';
|
|
24
25
|
import type { IKnowledgeStoreService } from './core/interfaces/knowledge-interfaces.ts';
|
|
@@ -45,7 +46,7 @@ import { buildModelRegistry as sharedBuildModelRegistry, resolveModel } from './
|
|
|
45
46
|
import { scrapeSingle } from './web-research/web-scraper.ts';
|
|
46
47
|
import { validateInitialLinks } from './utils/url-utils.ts';
|
|
47
48
|
import { validateUrlForSSRF, disposeSsrfSafeFetcher } from './web-research/scraper-utils.ts';
|
|
48
|
-
import type { ScrapeResult } from './core/interfaces/scheduler-interfaces.ts';
|
|
49
|
+
import type { ScrapeResult, ISchedulerService } from './core/interfaces/scheduler-interfaces.ts';
|
|
49
50
|
import { randomUUID } from 'node:crypto';
|
|
50
51
|
import type { ResearchDepth } from './types/index.ts';
|
|
51
52
|
|
|
@@ -278,11 +279,11 @@ async function _doInit(options: ResearchSDKOptions = {}): Promise<void> {
|
|
|
278
279
|
globalApiKey = options.apiKey || process.env['PI_RESEARCH_API_KEY'];
|
|
279
280
|
let parsedProvider = options.provider || process.env['PI_RESEARCH_PROVIDER'];
|
|
280
281
|
|
|
281
|
-
// An EXPLICIT model option must govern the entire run —
|
|
282
|
-
// ctx.model
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
282
|
+
// An EXPLICIT model option must govern the entire run — the initial
|
|
283
|
+
// coordinator (via ctx.model, same provider as the parent chat for prompt
|
|
284
|
+
// cache continuity), the evaluator/synthesis rounds, and researchers/
|
|
285
|
+
// synthesis (which resolve through RESEARCH_MODEL). Pin the config to the
|
|
286
|
+
// explicit choice so the run is consistent. No-op when the option was itself
|
|
286
287
|
// seeded from the config (the CLI does that).
|
|
287
288
|
if (options.model) {
|
|
288
289
|
const explicit = typeof options.model === 'string'
|
|
@@ -316,10 +317,12 @@ async function _doInit(options: ResearchSDKOptions = {}): Promise<void> {
|
|
|
316
317
|
// Resolve the model. Precedence: explicit option (a Model object is used
|
|
317
318
|
// as-is; it need not exist in the registry) → configured RESEARCH_MODEL →
|
|
318
319
|
// provider default → registry fallback. Seeding the resolver with the
|
|
319
|
-
// configured model is what keeps the
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
320
|
+
// configured model is what keeps the evaluator/synthesis rounds and
|
|
321
|
+
// researchers (which read RESEARCH_MODEL themselves) on the same model —
|
|
322
|
+
// without it a configured model would govern only the sub-agents and
|
|
323
|
+
// planning would silently run on the fallback. The initial coordinator
|
|
324
|
+
// (round 1) always uses ctx.model (the parent chat's model) for prompt-
|
|
325
|
+
// cache continuity, regardless of this seeding.
|
|
323
326
|
if (effectiveModel && typeof effectiveModel === 'object' && (effectiveModel as any).id) {
|
|
324
327
|
globalModel = effectiveModel as Model<any>;
|
|
325
328
|
} else {
|
|
@@ -355,6 +358,30 @@ async function _doInit(options: ResearchSDKOptions = {}): Promise<void> {
|
|
|
355
358
|
isInitialized = true;
|
|
356
359
|
logger.log('[SDK] Research SDK initialized successfully');
|
|
357
360
|
|
|
361
|
+
// Stop the browser scheduler as the FIRST teardown step of a shutdown. This
|
|
362
|
+
// runs on EVERY teardown path — signal handlers (SIGINT/SIGTERM) and the
|
|
363
|
+
// cooperative shutdownResearchSDK() alike — which is intended: without it the
|
|
364
|
+
// sdk_shutdown cleanup list is empty ("No cleanup tasks registered"), and the scheduler's cluster workers died mid-DAG-disposal —
|
|
365
|
+
// with code null — while the worker pool was still live, which the pool's
|
|
366
|
+
// exitHandler counted as unexpected deaths ("3 consecutive exits → pool
|
|
367
|
+
// unhealthy") on a process that was exiting anyway. Shutting down here puts
|
|
368
|
+
// those deaths inside the pool's intentional-destroy window instead. Both
|
|
369
|
+
// teardown paths are idempotent (the later DAG dispose no-ops), and the task
|
|
370
|
+
// is bounded well inside the 15s signal force-exit deadline. Registered per
|
|
371
|
+
// init: runCleanup detaches the task set after running, so re-init gets a
|
|
372
|
+
// fresh registration.
|
|
373
|
+
shutdownManager.register(async () => {
|
|
374
|
+
if (!globalContainer) return;
|
|
375
|
+
const schedulerService = tryGetService<ISchedulerService>(ServiceNames.SCHEDULER, globalContainer);
|
|
376
|
+
const scheduler = schedulerService?.getScheduler?.();
|
|
377
|
+
if (!scheduler) return;
|
|
378
|
+
try {
|
|
379
|
+
await raceWithDeadline(scheduler.shutdown(), 12000);
|
|
380
|
+
} catch (err) {
|
|
381
|
+
logger.error('[SDK] Error stopping scheduler during shutdown cleanup:', err);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
358
385
|
// Register graceful-shutdown signal handlers (SIGINT/SIGTERM).
|
|
359
386
|
// These are a safety net for scripts and long-running embedders —
|
|
360
387
|
// the cooperative shutdownResearchSDK() API is still the primary path.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { Question, Answer, User, Site } from '../types.ts';
|
|
6
|
+
import { plainUrl } from '../../utils/plain-url.ts';
|
|
6
7
|
|
|
7
8
|
export function formatQuestionsCompact(questions: Question[]): string {
|
|
8
9
|
if (questions.length === 0) {
|
|
@@ -13,7 +14,7 @@ export function formatQuestionsCompact(questions: Question[]): string {
|
|
|
13
14
|
let index = 1;
|
|
14
15
|
for (const q of questions) {
|
|
15
16
|
const accepted = q.accepted_answer_id ? '[accepted]' : ' ';
|
|
16
|
-
lines.push(`${index}. ${accepted}
|
|
17
|
+
lines.push(`${index}. ${accepted} ${q.title} (${q.score} pts, ${q.answer_count} ans, ${q.view_count} views) ${plainUrl(q.link)}`);
|
|
17
18
|
index++;
|
|
18
19
|
}
|
|
19
20
|
return lines.join('\n');
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { Question, Answer, User, Site } from '../types.ts';
|
|
6
|
+
import { plainUrl, fenceBlock } from '../../utils/plain-url.ts';
|
|
6
7
|
|
|
7
8
|
export function formatQuestionsTable(questions: Question[]): string {
|
|
8
9
|
if (questions.length === 0) {
|
|
@@ -21,7 +22,10 @@ export function formatQuestionsTable(questions: Question[]): string {
|
|
|
21
22
|
if (q.owner) {
|
|
22
23
|
output += `- **Author:** ${q.owner.display_name} (rep: ${q.owner.reputation})\n`;
|
|
23
24
|
}
|
|
24
|
-
output
|
|
25
|
+
// plainUrl: URLs in tool output are data, not interactive links — see
|
|
26
|
+
// plain-url.ts. Bare URLs would be GFM-autolinked into clickable OSC 8
|
|
27
|
+
// hyperlinks by markdown renderers (accidental browser-tab opens).
|
|
28
|
+
output += `- **Link:** ${plainUrl(q.link)}\n`;
|
|
25
29
|
output += `- **Created:** ${new Date(q.creation_date * 1000).toLocaleString()}\n`;
|
|
26
30
|
|
|
27
31
|
if (q.body) {
|
|
@@ -29,7 +33,13 @@ export function formatQuestionsTable(questions: Question[]): string {
|
|
|
29
33
|
// previous `length < 1000` guard omitted the Body line for the longest
|
|
30
34
|
// questions (the ones most in need of truncation).
|
|
31
35
|
const body = q.body.length > 500 ? `${q.body.substring(0, 500)}...` : q.body;
|
|
32
|
-
|
|
36
|
+
// Third-party body text must never be rendered as markdown — it can
|
|
37
|
+
// carry arbitrary links (markdown injection → clickable browser tabs).
|
|
38
|
+
// The fence MUST start on its own line: CommonMark only recognizes a
|
|
39
|
+
// fenced code block when the opening fence begins the line; inline
|
|
40
|
+
// after "**Body:**" it degrades to paragraph text and marked renders
|
|
41
|
+
// embedded links clickable (verified against pi's bundled marked).
|
|
42
|
+
output += `- **Body:**\n\n${fenceBlock(body)}\n`;
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
output += '\n---\n\n';
|
|
@@ -62,7 +72,8 @@ export function formatAnswersTable(answers: Answer[]): string {
|
|
|
62
72
|
// answers (same class as the question-body fix above; answers get a more
|
|
63
73
|
// generous bound since the body is the whole point of an answer).
|
|
64
74
|
const body = a.body.length > 2000 ? `${a.body.substring(0, 2000)}...` : a.body;
|
|
65
|
-
|
|
75
|
+
// Same third-party-content rule as the question body above.
|
|
76
|
+
output += `\n### Answer Body\n\n${fenceBlock(body)}\n`;
|
|
66
77
|
}
|
|
67
78
|
|
|
68
79
|
output += '\n---\n\n';
|
|
@@ -89,9 +100,9 @@ export function formatUsersTable(users: User[]): string {
|
|
|
89
100
|
output += `- **Location:** ${u.location}\n`;
|
|
90
101
|
}
|
|
91
102
|
if (u.website_url) {
|
|
92
|
-
output += `- **Website:** ${u.website_url}\n`;
|
|
103
|
+
output += `- **Website:** ${plainUrl(u.website_url)}\n`;
|
|
93
104
|
}
|
|
94
|
-
output += `- **Profile:** ${u.link}\n`;
|
|
105
|
+
output += `- **Profile:** ${plainUrl(u.link)}\n`;
|
|
95
106
|
output += '\n---\n\n';
|
|
96
107
|
}
|
|
97
108
|
|
|
@@ -125,7 +136,7 @@ export function formatCompactQuestions(questions: Question[]): string {
|
|
|
125
136
|
let index = 1;
|
|
126
137
|
for (const q of questions) {
|
|
127
138
|
const accepted = q.accepted_answer_id ? '[accepted]' : '';
|
|
128
|
-
output += `${index}.
|
|
139
|
+
output += `${index}. ${q.title} ${accepted} (score: ${q.score}, answers: ${q.answer_count}) ${plainUrl(q.link)}\n`;
|
|
129
140
|
index++;
|
|
130
141
|
}
|
|
131
142
|
return output;
|
|
@@ -343,7 +343,7 @@ import { safeGetApiKeyAndHeaders } from '../core/llm/model-registry-factory.ts';
|
|
|
343
343
|
|
|
344
344
|
/**
|
|
345
345
|
* Resolve the model to use for the background synthesis LLM.
|
|
346
|
-
* Priority: RESEARCH_MODEL → ctx.model
|
|
346
|
+
* Priority: explicit modelId → RESEARCH_MODEL → ctx.model → fallback
|
|
347
347
|
*/
|
|
348
348
|
function resolveSynthesisModel(ctx: ExtensionContext, config: Config): { model?: Model<any>; error?: string } {
|
|
349
349
|
try {
|
|
@@ -70,6 +70,7 @@ export function createStackexchangeTool(options: {
|
|
|
70
70
|
'Use tags to filter by specific topics.',
|
|
71
71
|
'Use maxPages parameter to control pagination for search results (default: 5 pages).',
|
|
72
72
|
`CRITICAL: You are allowed a maximum of ${getMaxGatheringCalls(options.config)} gathering calls across your web tools (search, security_search, stackexchange). Use them for breadth.`,
|
|
73
|
+
'URLs in results are wrapped in backticks on purpose: they are data, not clickable links. Never reformat them as markdown links — a click in the terminal opens a real browser tab.',
|
|
73
74
|
],
|
|
74
75
|
parameters: StackExchangeParamsSchema,
|
|
75
76
|
executionMode: 'parallel',
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent';
|
|
11
|
-
import { matchesKey } from '@earendil-works/pi-tui';
|
|
11
|
+
import { isKeyRelease, matchesKey } from '@earendil-works/pi-tui';
|
|
12
12
|
import { getActiveSessionCount, abortAllSessions, refreshAllSessions, hideMasterWidget } from '../orchestration/session-state.ts';
|
|
13
13
|
import { logger } from '../logger.ts';
|
|
14
14
|
|
|
@@ -56,6 +56,26 @@ export function initGlobalTuiController(ui: ExtensionUIContext, piSessionId?: st
|
|
|
56
56
|
|
|
57
57
|
*/
|
|
58
58
|
const handleTerminalInput = (data: string) => {
|
|
59
|
+
// Key-RELEASE events must be ignored. Pi runs the Kitty keyboard protocol
|
|
60
|
+
// with flag 2 (report event types), so every physical key sends TWO
|
|
61
|
+
// sequences: the press, then a release ~100-200ms later
|
|
62
|
+
// (e.g. ctrl+c press \x1b[99;5u, release \x1b[99;5:3u). Tui.handleTerminalInput
|
|
63
|
+
// filters releases — but ONLY for the focused component, AFTER extension
|
|
64
|
+
// input listeners run, and matchesKey cannot tell them apart
|
|
65
|
+
// (matchesKittySequence parses codepoint+modifier but never the event type,
|
|
66
|
+
// so a RELEASE matches matchesKey(data, 'ctrl+c') just like a press).
|
|
67
|
+
// Observed live 2026-09-03: one physical Ctrl+C with text in the editor
|
|
68
|
+
// logged "with editor text — clearing only" and then, 150ms later, the
|
|
69
|
+
// RELEASE event matched ctrl+c, saw the editor pi had just cleared, and
|
|
70
|
+
// aborted the research run. The same double-fire misclassified old
|
|
71
|
+
// ctrl+c releases as "Esc" aborts in the logs. pi's own components never
|
|
72
|
+
// see releases (tui filters them), so dropping them here matches pi's
|
|
73
|
+
// semantics exactly. Key REPEATS (\x1b[99;5:2u) are genuine held-key intent
|
|
74
|
+
// and keep flowing, like they do to pi's own components.
|
|
75
|
+
if (isKeyRelease(data)) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
59
79
|
// If an interactive TUI is active (like the config menu),
|
|
60
80
|
// let it handle ALL input. We don't want to trigger a global abort
|
|
61
81
|
// while the user is navigating a menu.
|
|
@@ -66,14 +86,25 @@ export function initGlobalTuiController(ui: ExtensionUIContext, piSessionId?: st
|
|
|
66
86
|
// Check for specific global cancel keys (Escape and Ctrl+C)
|
|
67
87
|
// We only trigger abort if research is actually running.
|
|
68
88
|
if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) {
|
|
69
|
-
// Ctrl+C is pi's app.clear ("Clear editor"), NOT its cancel key — pi's own
|
|
70
|
-
// handleCtrlC() clears the editor text and never interrupts the agent (Esc
|
|
71
|
-
// is app.interrupt). Aborting the run on every Ctrl+C stole the key from
|
|
72
|
-
// users who merely wanted to discard typed text mid-run. Follow pi's
|
|
73
|
-
// convention: with text in the editor, Ctrl+C only clears (fall through and
|
|
74
|
-
// let pi do the clearing); an EMPTY editor is an explicit cancel gesture and
|
|
75
|
-
// aborts the active research.
|
|
76
89
|
if (matchesKey(data, 'ctrl+c')) {
|
|
90
|
+
// Ctrl+C is pi's app.clear ("Clear editor"), NOT its cancel key — Esc is
|
|
91
|
+
// app.interrupt. But pi's handleCtrlC() is TIME-based, not content-based:
|
|
92
|
+
// first press clears the editor and arms a window, and a SECOND press
|
|
93
|
+
// within 500ms calls shutdown() — exiting the entire pi process. There is
|
|
94
|
+
// no "abort research" step anywhere in it. Extension input listeners run
|
|
95
|
+
// BEFORE the focused editor and may consume keys, so this handler fully
|
|
96
|
+
// owns the Ctrl+C policy while a run is active:
|
|
97
|
+
//
|
|
98
|
+
// editor has text → fall through: pi clears the text, run continues
|
|
99
|
+
// (a follow-up press sees an empty editor → abort
|
|
100
|
+
// branch below — the two-step cancel the user asked
|
|
101
|
+
// for: clear first, second press aborts the run)
|
|
102
|
+
// editor empty AND → abort the run AND CONSUME the key. Consuming is
|
|
103
|
+
// run active the fix: without it the abort fell through to pi's
|
|
104
|
+
// handleCtrlC, and a double-press within 500ms (the
|
|
105
|
+
// natural way to "abort now") shut down pi itself.
|
|
106
|
+
// editor empty AND → fall through untouched: pi's native behavior
|
|
107
|
+
// no run active (clear/arm, double-press exit) must keep working.
|
|
77
108
|
let editorText = '';
|
|
78
109
|
try {
|
|
79
110
|
editorText = typeof ui.getEditorText === 'function' ? (ui.getEditorText() ?? '') : '';
|
|
@@ -82,13 +113,27 @@ export function initGlobalTuiController(ui: ExtensionUIContext, piSessionId?: st
|
|
|
82
113
|
logger.debug('[TUI] Ctrl+C with editor text — clearing only, research keeps running.');
|
|
83
114
|
return undefined;
|
|
84
115
|
}
|
|
116
|
+
const activeCount = getActiveSessionCount();
|
|
117
|
+
if (activeCount > 0) {
|
|
118
|
+
logger.info('[TUI] Ctrl+C with empty editor — aborting active research (key consumed so pi\'s double-press exit cannot fire).');
|
|
119
|
+
try {
|
|
120
|
+
// Scope to this Pi session to avoid cross-session interference
|
|
121
|
+
abortAllSessions(state.piSessionId);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
logger.error('[TUI] Failed to abort sessions on Ctrl+C:', err);
|
|
124
|
+
}
|
|
125
|
+
return { consume: true };
|
|
126
|
+
}
|
|
127
|
+
// No research running — native pi Ctrl+C (clear editor / double-press exit).
|
|
128
|
+
return undefined;
|
|
85
129
|
}
|
|
130
|
+
|
|
131
|
+
// Esc (pi's app.interrupt): panic-cancel regardless of editor text. Abort the
|
|
132
|
+
// active run, then let the key fall through so Pi can also respond
|
|
133
|
+
// (cancelling its own agent loop) and other TUIs can react.
|
|
86
134
|
const activeCount = getActiveSessionCount();
|
|
87
135
|
if (activeCount > 0) {
|
|
88
|
-
|
|
89
|
-
// instead of clearAllSessionState() which nukes ALL sessions across ALL Pi instances.
|
|
90
|
-
logger.info(`[TUI] Global cancel requested (${data === '\x03' ? 'Ctrl+C' : 'Esc'}). Aborting active sessions...`);
|
|
91
|
-
|
|
136
|
+
logger.info('[TUI] Global cancel requested (Esc). Aborting active sessions...');
|
|
92
137
|
try {
|
|
93
138
|
// Scope to this Pi session to avoid cross-session interference
|
|
94
139
|
abortAllSessions(state.piSessionId);
|
|
@@ -96,9 +141,6 @@ export function initGlobalTuiController(ui: ExtensionUIContext, piSessionId?: st
|
|
|
96
141
|
logger.error('[TUI] Failed to abort sessions on cancel:', err);
|
|
97
142
|
}
|
|
98
143
|
}
|
|
99
|
-
|
|
100
|
-
// Let the key fall through so Pi can handle its own cancellation
|
|
101
|
-
// or other TUIs can respond.
|
|
102
144
|
return undefined;
|
|
103
145
|
}
|
|
104
146
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neutralize external URLs in tool output so terminal-UI markdown renderers
|
|
3
|
+
* cannot turn them into clickable hyperlinks.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: pi's TUI (and most markdown-rendering terminals) convert
|
|
6
|
+
* markdown links AND bare URLs (GFM autolink) into OSC 8 terminal hyperlinks,
|
|
7
|
+
* and a mouse press+release on such a span opens the URL in the user's real
|
|
8
|
+
* browser. Tool results are agent-facing data, not interactive UI — a URL
|
|
9
|
+
* appearing there should never be one accidental click away from opening a
|
|
10
|
+
* tab, and third-party content (question bodies, API fields) must not be able
|
|
11
|
+
* to inject clickable links into the user's terminal at all.
|
|
12
|
+
*
|
|
13
|
+
* Mechanism: wrapping a URL in backticks makes it a markdown code span. Code
|
|
14
|
+
* spans are tokenized before autolink in every CommonMark/GFM tokenizer
|
|
15
|
+
* (verified against the `marked` version pi ships), so the URL renders as
|
|
16
|
+
* literal text — copyable by humans, trivially extractable by the model, and
|
|
17
|
+
* impossible to click.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Longest run of backticks that can safely fence arbitrary content. */
|
|
21
|
+
const MIN_FENCE = 3;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Return `url` as a markdown code span: renders as plain text everywhere,
|
|
25
|
+
* never becomes a clickable hyperlink, and stays byte-exact for the model.
|
|
26
|
+
*/
|
|
27
|
+
export function plainUrl(url: string): string {
|
|
28
|
+
const safe = url.replace(/`/g, '%60');
|
|
29
|
+
return `\`${safe}\``;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Wrap arbitrary third-party text (question bodies, API-provided markup) in a
|
|
34
|
+
* fenced code block so no markdown inside it — links, headings, tables — is
|
|
35
|
+
* ever rendered or autolinked. The fence length grows past any backtick run
|
|
36
|
+
* found in the content, so content containing ``` fences cannot close it
|
|
37
|
+
* early.
|
|
38
|
+
*/
|
|
39
|
+
export function fenceBlock(content: string): string {
|
|
40
|
+
const runs = content.match(/`+/g) ?? [];
|
|
41
|
+
const longest = runs.reduce((max, run) => Math.max(max, run.length), 0);
|
|
42
|
+
const fence = '`'.repeat(Math.max(MIN_FENCE, longest + 1));
|
|
43
|
+
return `${fence}text\n${content}\n${fence}`;
|
|
44
|
+
}
|
|
@@ -205,11 +205,21 @@ function messageIsTransient(message: string): boolean {
|
|
|
205
205
|
// here on purpose — the generic classifier keeps "timeout"/"etimedout"; the LLM
|
|
206
206
|
// wrapper's "cancelled or timed out" wording is handled by the planning-service
|
|
207
207
|
// classifier, so a security/youtube caller's abort string isn't reinterpreted.
|
|
208
|
+
//
|
|
209
|
+
// "connection error" is the OpenAI SDK's APIConnectionError default message (the
|
|
210
|
+
// shape pi-ai surfaces for openai-completions providers like OpenRouter when the
|
|
211
|
+
// fetch itself fails before any response — refused / reset / DNS / TLS). The SDK
|
|
212
|
+
// only throws this class for fetch-level transport failures (its own 2 internal
|
|
213
|
+
// retries already exhausted), so the string is a reliable transient signal. Seen
|
|
214
|
+
// live 2026-09-02: OpenRouter planning call died in 4s with zero retries because
|
|
215
|
+
// this pattern was missing — and the same miss made isDegradableLlmError throw
|
|
216
|
+
// instead of degrading to the fallback plan. Both layers gate on this matcher.
|
|
208
217
|
if (
|
|
209
218
|
m.includes('econnrefused') || m.includes('enotfound') || m.includes('timeout') ||
|
|
210
219
|
m.includes('etimedout') || m.includes('econnreset') || m.includes('fetch failed') ||
|
|
211
220
|
m.includes('terminated') || m.includes('other side closed') ||
|
|
212
|
-
m.includes('socket hang up') || m.includes('und_err')
|
|
221
|
+
m.includes('socket hang up') || m.includes('und_err') ||
|
|
222
|
+
m.includes('connection error')
|
|
213
223
|
) {
|
|
214
224
|
return true;
|
|
215
225
|
}
|