@lincoln504/pi-research 1.6.10 → 1.6.11

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/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.
@@ -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 {
@@ -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
 
@@ -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
  }