@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.
@@ -104,13 +104,15 @@ export class DeepResearchOrchestrator {
104
104
  const observer = this.observer;
105
105
  const container = tryGetServiceContainerFromCtx(ctx);
106
106
 
107
- // RESEARCH_MODEL contract (config.ts): the coordinator and evaluator always
108
- // use the caller's model (ctx.model); RESEARCH_MODEL overrides only the
109
- // researcher sub-agents. `model` is the resolved research model — it equals
110
- // ctx.model on the SDK/CLI path, but on the pi-tool path it is the
111
- // RESEARCH_MODEL override, so planning must key off ctx.model explicitly.
112
- // Fall back to `model` when the host exposes no ctx.model.
113
- const coordinatorModel = (ctx.model as Model<any> | undefined) ?? model;
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: coordinatorModel,
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: coordinatorModel,
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: coordinatorModel,
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 — coordinator (via
282
- // ctx.model) AND researchers/synthesis (which resolve through
283
- // RESEARCH_MODEL, where config would otherwise outrank the option and split
284
- // the run across two models, with the report metadata naming the wrong one).
285
- // Pin the config to the explicit choice. No-op when the option was itself
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 coordinator (which runs on ctx.model)
320
- // on the SAME model as the researchers/synthesis (which read
321
- // RESEARCH_MODEL themselves) — without it a configured model would govern
322
- // only the sub-agents and planning would silently run on the fallback.
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} [${q.title}](${q.link}) (${q.score} pts, ${q.answer_count} ans, ${q.view_count} views)`);
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 += `- **Link:** ${q.link}\n`;
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
- output += `- **Body:** ${body}\n`;
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
- output += `\n### Answer Body\n\n${body}\n`;
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}. [${q.title}](${q.link}) ${accepted} (score: ${q.score}, answers: ${q.answer_count})\n`;
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
- // FIX (New Issues B & E): Use abortAllSessions scoped to the current Pi session
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
  }