@lincoln504/pi-research 1.1.2 → 1.3.0

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 (119) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.mjs +2599 -1268
  3. package/dist/thread-worker.mjs +224 -147
  4. package/docs/AGENT-SKILL.md +11 -4
  5. package/docs/ARCHITECTURE.md +20 -10
  6. package/docs/CHANGELOG.md +195 -17
  7. package/docs/CONFIGURATION.md +9 -7
  8. package/docs/KNOWLEDGE-STORE.md +4 -3
  9. package/docs/PI-EXTENSION.md +1 -1
  10. package/docs/SDK.md +55 -0
  11. package/docs/THIRD-PARTY-NOTICES.md +1 -1
  12. package/package.json +5 -4
  13. package/scripts/cleanup.cjs +114 -12
  14. package/scripts/prepare.cjs +15 -5
  15. package/scripts/setup.cjs +24 -4
  16. package/skills/pi-research/README.md +9 -2
  17. package/skills/pi-research/SKILL.md +6 -3
  18. package/skills/pi-research/references/configuration.md +6 -2
  19. package/skills/pi-research/scripts/run.mjs +24 -2
  20. package/src/cleanup/research-cleanup.ts +12 -4
  21. package/src/cli.ts +372 -86
  22. package/src/config.ts +134 -17
  23. package/src/constants.ts +31 -0
  24. package/src/core/interfaces/health-check-interfaces.ts +3 -39
  25. package/src/core/interfaces/knowledge-interfaces.ts +6 -1
  26. package/src/core/interfaces/planning-interfaces.ts +8 -0
  27. package/src/core/interfaces/service-names.ts +0 -1
  28. package/src/core/interfaces/state-manager-interfaces.ts +11 -3
  29. package/src/core/llm/agentic-repair.ts +14 -0
  30. package/src/core/pi-version.ts +136 -0
  31. package/src/core/planning-service.ts +43 -1
  32. package/src/core/service-initialization.ts +6 -14
  33. package/src/core/service-interfaces.ts +1 -1
  34. package/src/core/service-registry.ts +117 -8
  35. package/src/healthcheck/index.ts +7 -2
  36. package/src/index.ts +11 -18
  37. package/src/infrastructure/browser/browser-client.ts +80 -13
  38. package/src/infrastructure/browser/browser-error-utils.ts +6 -1
  39. package/src/infrastructure/browser/browser-server.ts +41 -12
  40. package/src/infrastructure/browser/browser-task-scheduler.ts +159 -35
  41. package/src/infrastructure/browser/cleanup-utils.ts +95 -9
  42. package/src/infrastructure/browser/client-agent.ts +6 -5
  43. package/src/infrastructure/browser/config.ts +14 -3
  44. package/src/infrastructure/browser/priority-task-queue.ts +10 -3
  45. package/src/infrastructure/browser/scheduler-factory.ts +44 -4
  46. package/src/infrastructure/browser/task-execution-service.ts +92 -28
  47. package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
  48. package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
  49. package/src/infrastructure/browser/thread-worker-types.ts +3 -1
  50. package/src/infrastructure/browser/thread-worker.mjs +224 -147
  51. package/src/infrastructure/browser/worker-pool-manager.ts +18 -5
  52. package/src/infrastructure/embedding/embedding-client.ts +22 -8
  53. package/src/infrastructure/embedding/embedding-factory.ts +61 -15
  54. package/src/infrastructure/embedding/embedding-server.ts +105 -11
  55. package/src/infrastructure/file-lock-service.ts +295 -28
  56. package/src/infrastructure/knowledge-store-service.ts +48 -3
  57. package/src/infrastructure/process-lifecycle-service.ts +134 -34
  58. package/src/infrastructure/research-run-semaphore.ts +37 -3
  59. package/src/infrastructure/state/state-backup-manager.ts +5 -7
  60. package/src/infrastructure/state/state-browser-api.ts +5 -2
  61. package/src/infrastructure/state/state-browser-manager.ts +15 -1
  62. package/src/infrastructure/state/state-manager-service.ts +5 -5
  63. package/src/infrastructure/state/state-manager.ts +34 -23
  64. package/src/infrastructure/types/state-types.ts +9 -0
  65. package/src/knowledge/chunker.ts +88 -1
  66. package/src/knowledge/embedder-init.ts +1 -1
  67. package/src/knowledge/embedder.ts +188 -46
  68. package/src/knowledge/index.ts +1 -1
  69. package/src/knowledge/store.ts +101 -21
  70. package/src/knowledge/webgpu-viability.ts +9 -4
  71. package/src/knowledge/writer-queue.ts +88 -46
  72. package/src/logger.ts +5 -1
  73. package/src/observers/research-observer-impl.ts +31 -0
  74. package/src/orchestration/abort-utils.ts +60 -0
  75. package/src/orchestration/deep-research-orchestrator.ts +25 -4
  76. package/src/orchestration/headless-observer.ts +8 -0
  77. package/src/orchestration/quick-research-orchestrator.ts +2 -2
  78. package/src/orchestration/research-orchestration-service.ts +65 -15
  79. package/src/orchestration/research-session-service.ts +37 -11
  80. package/src/orchestration/research-synthesis-service.ts +33 -11
  81. package/src/orchestration/researcher-executor.ts +100 -20
  82. package/src/orchestration/researcher.ts +39 -20
  83. package/src/orchestration/session-state.ts +11 -0
  84. package/src/sdk.ts +125 -19
  85. package/src/security/cisa-kev.ts +10 -3
  86. package/src/security/github-advisories.ts +18 -7
  87. package/src/security/index.ts +51 -30
  88. package/src/security/nvd-client.ts +24 -8
  89. package/src/security/osv-client.ts +14 -4
  90. package/src/skill-install/skill-installer.ts +153 -11
  91. package/src/stackexchange/index.ts +20 -5
  92. package/src/stackexchange/output/table.ts +7 -2
  93. package/src/stackexchange/rest-client.ts +41 -3
  94. package/src/tools/research-knowledge-search.ts +15 -0
  95. package/src/tools/research-tool-definition.ts +8 -2
  96. package/src/tools/scrape.ts +48 -25
  97. package/src/tools/search.ts +16 -2
  98. package/src/tools/security.ts +68 -7
  99. package/src/tui/research-health.ts +18 -2
  100. package/src/utils/atomic-replace.ts +101 -0
  101. package/src/utils/circuit-breaker.ts +9 -2
  102. package/src/utils/citation-utils.ts +3 -1
  103. package/src/utils/disk-space-checker.ts +11 -6
  104. package/src/utils/http-body.ts +176 -0
  105. package/src/utils/json-utils.ts +116 -76
  106. package/src/utils/llm-usage.ts +17 -1
  107. package/src/utils/log-rotation.ts +42 -10
  108. package/src/utils/log-utils.ts +40 -0
  109. package/src/utils/metrics-summary.ts +27 -9
  110. package/src/utils/stdio-capture.ts +63 -22
  111. package/src/utils/text-utils.ts +8 -1
  112. package/src/utils/tool-usage-tracker.ts +9 -2
  113. package/src/web-research/browser-search.ts +70 -5
  114. package/src/web-research/scraper-utils.ts +13 -0
  115. package/src/web-research/search.ts +25 -6
  116. package/src/web-research/types.ts +19 -4
  117. package/src/web-research/web-scraper.ts +72 -54
  118. package/src/youtube/transcript-client.ts +5 -1
  119. package/src/core/health-check-service.ts +0 -265
@@ -92,9 +92,12 @@ export class ResearchSynthesisService implements IService {
92
92
  this.sessions.set(sessionId, reports);
93
93
  return reports;
94
94
  }
95
- // Evict the least-recently-used session when at capacity. Because every
96
- // store/read touches its session to the end (above), the head is the genuinely
97
- // stalest session, not merely the first-created — so an in-flight run is spared.
95
+ // Evict the least-recently-used session when at capacity. Every STORE touches
96
+ // its session to the end (above); reads deliberately do not (peekSessionReports
97
+ // exists so a stray read cannot create-and-evict). A run in its read-only
98
+ // synthesis phase therefore stops refreshing its recency — tolerable only
99
+ // because reaching MAX_SESSIONS at all needs ~200 uncleaned sessions in one
100
+ // process (run cap 3, sessions cleared after each run), i.e. a leaking host.
98
101
  if (this.sessions.size >= ResearchSynthesisService.MAX_SESSIONS) {
99
102
  const oldestKey = this.sessions.keys().next().value;
100
103
  if (oldestKey !== undefined) {
@@ -107,6 +110,25 @@ export class ResearchSynthesisService implements IService {
107
110
  return reports;
108
111
  }
109
112
 
113
+ /**
114
+ * Read a session's reports WITHOUT creating an entry for an unknown id.
115
+ *
116
+ * The read-only accessors below must not go through `getSessionReports`: that
117
+ * is create-on-read, so a query for an id we have never seen inserts an empty
118
+ * map — and at MAX_SESSIONS that insert evicts the current head. The LRU touch
119
+ * protects sessions that are read or written, but an in-flight run whose
120
+ * researchers are all still executing (reports stored early, nothing touched
121
+ * since) is exactly the stalest key, so a stray read for an unknown session
122
+ * could evict a live run's reports and silently shrink its final synthesis.
123
+ *
124
+ * Returns a shared empty map for unknown ids — callers here only read.
125
+ */
126
+ private peekSessionReports(sessionId: string): ReadonlyMap<string, string> {
127
+ return this.sessions.get(sessionId) ?? ResearchSynthesisService.EMPTY_REPORTS;
128
+ }
129
+
130
+ private static readonly EMPTY_REPORTS: ReadonlyMap<string, string> = new Map<string, string>();
131
+
110
132
  /**
111
133
  * Store a researcher report
112
134
  * @param sessionId - Session identifier
@@ -121,14 +143,14 @@ export class ResearchSynthesisService implements IService {
121
143
  * Get a report by ID
122
144
  */
123
145
  getReport(sessionId: string, id: string): string | undefined {
124
- return this.getSessionReports(sessionId).get(id);
146
+ return this.peekSessionReports(sessionId).get(id);
125
147
  }
126
148
 
127
149
  /**
128
150
  * Get all reports
129
151
  */
130
152
  getAllReports(sessionId: string): Map<string, string> {
131
- return new Map(this.getSessionReports(sessionId));
153
+ return new Map(this.peekSessionReports(sessionId));
132
154
  }
133
155
 
134
156
  /**
@@ -137,7 +159,7 @@ export class ResearchSynthesisService implements IService {
137
159
  getReportsForRound(sessionId: string, round: number): Map<string, string> {
138
160
  const roundReports = new Map<string, string>();
139
161
  const prefix = `${round}.`;
140
- for (const [key, report] of this.getSessionReports(sessionId).entries()) {
162
+ for (const [key, report] of this.peekSessionReports(sessionId).entries()) {
141
163
  if (key.startsWith(prefix)) {
142
164
  roundReports.set(key, report);
143
165
  }
@@ -149,14 +171,14 @@ export class ResearchSynthesisService implements IService {
149
171
  * Get total number of reports
150
172
  */
151
173
  getReportCount(sessionId: string): number {
152
- return this.getSessionReports(sessionId).size;
174
+ return this.peekSessionReports(sessionId).size;
153
175
  }
154
176
 
155
177
  /**
156
178
  * Check if there are any reports
157
179
  */
158
180
  hasReports(sessionId: string): boolean {
159
- return this.getSessionReports(sessionId).size > 0;
181
+ return this.peekSessionReports(sessionId).size > 0;
160
182
  }
161
183
 
162
184
  /**
@@ -177,7 +199,7 @@ export class ResearchSynthesisService implements IService {
177
199
  * @returns Fallback synthesis string
178
200
  */
179
201
  buildFallbackSynthesis(sessionId: string, currentRound: number = 0): string {
180
- const reports = this.getSessionReports(sessionId);
202
+ const reports = this.peekSessionReports(sessionId);
181
203
  const reportCount = reports.size;
182
204
  const roundInfo = currentRound > 0 ? ` (up to Round ${currentRound})` : '';
183
205
  let synthesis = `Research Findings${roundInfo}\n\n`;
@@ -211,7 +233,7 @@ export class ResearchSynthesisService implements IService {
211
233
  * @returns Synthesis with guaranteed and verified CITED LINKS section
212
234
  */
213
235
  ensureCitedLinks(sessionId: string, synthesis: string): string {
214
- const reports = this.getSessionReports(sessionId);
236
+ const reports = this.peekSessionReports(sessionId);
215
237
 
216
238
  // Primary source: citations the researchers wrote into their reports. These are
217
239
  // the richest (they carry descriptions and source tags), so they win when present.
@@ -374,7 +396,7 @@ export class ResearchSynthesisService implements IService {
374
396
  extractAllCitations(sessionId: string): Array<{ url: string; description: string; source?: string }> {
375
397
  const seen = new Set<string>();
376
398
  const allCitations: Array<{ url: string; description: string; source?: string }> = [];
377
- const reports = this.getSessionReports(sessionId);
399
+ const reports = this.peekSessionReports(sessionId);
378
400
 
379
401
  for (const report of reports.values()) {
380
402
  const citations = parseCitations(report);
@@ -19,7 +19,8 @@ import { getService, tryGetServiceContainerFromCtx } from '../core/service-regis
19
19
  import type { ResearchSessionService } from './research-session-service.ts';
20
20
  import { loadPrompt } from '../core/llm/prompts.ts';
21
21
  import { injectCurrentDate } from '../core/llm/inject-date.ts';
22
- import { recordResearcherFailure, getSteeringMessages } from './session-state.ts';
22
+ import { recordResearcherFailure, getSteeringMessages, shouldStopResearch } from './session-state.ts';
23
+ import { isAbortSentinel, boundSessionAbort } from './abort-utils.ts';
23
24
  import type { RunResearcherOptions } from './orchestration-types.ts';
24
25
  import { search } from '../web-research/search.ts';
25
26
 
@@ -46,6 +47,15 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
46
47
 
47
48
  const id = String(researcherConfig.id);
48
49
  const container = tryGetServiceContainerFromCtx(ctx);
50
+
51
+ // Fast-stop self-check: when the run's failure threshold trips, runResearchers'
52
+ // fast-stop path aborts only REGISTERED sessions — a researcher that is still
53
+ // acquiring initial links, sleeping between attempts, or rebuilding a session
54
+ // has none, and would otherwise sail on into a full LLM session + scrape burst
55
+ // AFTER the run already returned and released its run-cap slot. Every launch /
56
+ // retry boundary below consults this and exits quietly instead (the stop error
57
+ // is the run's outcome, not this researcher's failure).
58
+ const stopRequested = (): boolean => shouldStopResearch(sessionId, researchId, config.MAX_FAILED_RESEARCHERS);
49
59
  observer?.onResearcherStart?.(id, researcherConfig.name, researcherConfig.goal, round);
50
60
  metrics.increment('researchers_launched_total', 1, { mode: 'deep', complexity: String(complexity), round: String(round) });
51
61
 
@@ -81,12 +91,12 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
81
91
  const maxLinkAttempts = config.RESEARCHER_MAX_RETRIES + 1;
82
92
  for (let linkAttempt = 1; linkAttempt <= maxLinkAttempts && effectiveInitialLinks.length === 0; linkAttempt++) {
83
93
  if (linkAttempt > 1) {
84
- if (signal?.aborted || container?.isDisposing) break;
94
+ if (signal?.aborted || container?.isDisposing || stopRequested()) break;
85
95
  const delay = Math.min(1000 * Math.pow(2, linkAttempt - 2), config.RESEARCHER_MAX_RETRY_DELAY_MS);
86
96
  logger.warn(`[ResearcherExecutor] Researcher ${id} has no initial search results; retry ${linkAttempt - 1}/${config.RESEARCHER_MAX_RETRIES} after ${delay}ms`);
87
97
  await new Promise(r => setTimeout(r, delay));
88
98
  }
89
- if (signal?.aborted || container?.isDisposing) break;
99
+ if (signal?.aborted || container?.isDisposing || stopRequested()) break;
90
100
  try {
91
101
  const queryResults = await search(retryQueries, config, signal, undefined, container);
92
102
  const urls = new Set<string>();
@@ -99,6 +109,17 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
99
109
  }
100
110
  }
101
111
 
112
+ // A cancel/teardown/fast-stop lands here with zero links because the loop
113
+ // above breaks on abort/disposal/stop — that is a clean stop, not a "search
114
+ // produced nothing" researcher failure. Return quietly (mirrors the
115
+ // cancellation treatment in runResearchers) instead of recording a failure,
116
+ // bumping the skip metric, and painting a red failed slice with a bogus root
117
+ // cause on a plain cancel.
118
+ if (signal?.aborted || container?.isDisposing || stopRequested()) {
119
+ logger.debug(`[ResearcherExecutor] Researcher ${id} cancelled while acquiring initial links; skipping quietly.`);
120
+ return;
121
+ }
122
+
102
123
  if (effectiveInitialLinks.length === 0) {
103
124
  logger.warn(`[ResearcherExecutor] Researcher ${id} has no initial search results or historical links after ${maxLinkAttempts} attempt(s); skipping.`);
104
125
  // Use the resolved sessionId (= piSessionId), NOT the raw ctx.sessionId: when
@@ -124,11 +145,44 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
124
145
  const deliveredSteeringIds = new Set<string>();
125
146
 
126
147
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
148
+ // Launch boundary: never build a session for a run that is already cancelled
149
+ // or fast-stopped. The abort sentinel keeps the wrapper's clean-cancel
150
+ // classification (runResearchers treats it exactly like the race's throw);
151
+ // the fast-stop exit is quiet — see stopRequested above.
152
+ if (signal?.aborted) throw new Error('Aborted');
153
+ if (stopRequested()) {
154
+ logger.debug(`[ResearcherExecutor] Researcher ${id} exiting before attempt ${attempt}: fast-stop threshold crossed.`);
155
+ return;
156
+ }
127
157
  if (attempt > 1) {
128
158
  const delay = Math.min(1000 * Math.pow(2, attempt - 2), config.RESEARCHER_MAX_RETRY_DELAY_MS);
129
159
  logger.warn(`[ResearcherExecutor] Researcher ${id} retry ${attempt - 1}/${config.RESEARCHER_MAX_RETRIES} after ${delay}ms`);
130
160
  observer?.onResearcherProgress?.(id, 'retry');
131
- await new Promise(r => setTimeout(r, delay));
161
+ // Abortable: a cancel during this backoff must wake the sleep immediately —
162
+ // an inert setTimeout held the loop for up to RESEARCHER_MAX_RETRY_DELAY_MS
163
+ // after the cancel and then re-entered a full attempt (fresh session +
164
+ // billed prompt). Mirrors the stagger sleep in runResearchers.
165
+ await new Promise<void>((resolve) => {
166
+ const timer = setTimeout(() => {
167
+ if (signal) signal.removeEventListener('abort', onAbort);
168
+ resolve();
169
+ }, delay);
170
+ const onAbort = () => { clearTimeout(timer); resolve(); };
171
+ if (signal) {
172
+ if (signal.aborted) onAbort();
173
+ else signal.addEventListener('abort', onAbort, { once: true });
174
+ }
175
+ });
176
+ // Re-launch boundary after the sleep: a cancel, fast-stop, or container
177
+ // teardown landing DURING the backoff must not re-enter a full attempt —
178
+ // this is exactly the window the fast-stop path cannot reach, because
179
+ // abortAllSessions only aborts registered sessions and a between-attempts
180
+ // researcher has none.
181
+ if (signal?.aborted) throw new Error('Aborted');
182
+ if (stopRequested() || container?.isDisposing) {
183
+ logger.debug(`[ResearcherExecutor] Researcher ${id} exiting after backoff: fast-stop or container teardown.`);
184
+ return;
185
+ }
132
186
  }
133
187
 
134
188
  // Generate prompt for this attempt, incorporating ALL steering messages delivered so far
@@ -229,6 +283,32 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
229
283
  throw registrationError;
230
284
  }
231
285
 
286
+ // Post-registration self-check: this session is now visible to
287
+ // abortAllSessions(), but that scan is point-in-time — a stop/cancel that
288
+ // landed anywhere between createResearcherSession() resolving and
289
+ // registerSession() actually running (the `await getService(...)` above can
290
+ // yield the event loop) would have missed this session entirely, and every
291
+ // other guard in this function only fires BEFORE session creation or
292
+ // BETWEEN attempts. Nothing between here and session.prompt() below awaits
293
+ // anything, so this single check closes the whole window: either it fires
294
+ // here, or the run is not stopping and prompt() is about to make this
295
+ // session's registration meaningful. Mirrors the top-of-loop launch
296
+ // boundary above exactly (signal throws, fast-stop returns quietly) —
297
+ // deliberately NOT container?.isDisposing: like that boundary, this is
298
+ // still attempt 1's launch, and a disposing container must let this
299
+ // attempt's own session.prompt() fail naturally into the catch block's
300
+ // disposal classification rather than being pre-empted here.
301
+ if (signal?.aborted || stopRequested()) {
302
+ logger.debug(`[ResearcherExecutor] Researcher ${id} exiting right after session creation: fast-stop/cancel landed before prompt() was reached.`);
303
+ await boundSessionAbort(
304
+ session.abort().catch((err) => logger.warn(`[ResearcherExecutor] Failed to abort researcher session ${id} on post-creation fast-stop:`, err)),
305
+ () => logger.warn(`[ResearcherExecutor] Researcher ${id} session abort did not settle within 10s; continuing cleanup`),
306
+ );
307
+ sessionService.unregisterSession(researchId, id);
308
+ if (signal?.aborted) throw new Error('Aborted');
309
+ return;
310
+ }
311
+
232
312
  let lastSteeringCheck = Date.now();
233
313
 
234
314
  // User-supplied observer callbacks (SDK consumers) are invoked from inside
@@ -399,13 +479,19 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
399
479
  logger.debug(`[ResearcherExecutor] Researcher ${id} salvage attempt failed:`, salvageErr);
400
480
  }
401
481
 
402
- // Skip retries when the run is being torn down: an aborted signal, an explicit
403
- // "Aborted" error, or the service container entering disposal (SIGTERM/quit
404
- // mid-run, which throws "…during container disposal" from getService). Retrying
405
- // during disposal is futile — services are being destroyed — and it relaunches
406
- // search bursts into a WorkerPool that dispose() is simultaneously destroying,
407
- // producing a storm of "Cannot execute a task on destroying pool" errors.
408
- if (signal?.aborted || errMsg === 'Aborted' || errMsg.includes('during container disposal') || container?.isDisposing) {
482
+ // Skip retries when the run is being torn down: an aborted signal, the abort
483
+ // sentinel, or the service container entering disposal (SIGTERM/quit mid-run,
484
+ // which throws "…during container disposal" from getService). The sentinel has
485
+ // TWO forms — the race's bare 'Aborted' above AND the '<id>: Aborted' that
486
+ // ensureAssistantResponse throws when a fast-stop abortAllSessions() aborted
487
+ // this session externally (prompt() then resolves normally) — so match it via
488
+ // isAbortSentinel, not exact equality: the prefixed form used to slip past
489
+ // this guard and RETRY a researcher the fast-stop path had just aborted.
490
+ // Retrying during disposal is futile — services are being destroyed — and it
491
+ // relaunches search bursts into a WorkerPool that dispose() is simultaneously
492
+ // destroying, producing a storm of "Cannot execute a task on destroying pool"
493
+ // errors.
494
+ if (signal?.aborted || isAbortSentinel(errMsg) || errMsg.includes('during container disposal') || container?.isDisposing) {
409
495
  logger.debug(`[ResearcherExecutor] Researcher ${id} aborted or container disposing, skipping retries.`);
410
496
  break; // Break out of the attempt loop
411
497
  }
@@ -423,18 +509,12 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
423
509
  // never settle — never let per-attempt cleanup hang the retry loop on it. The
424
510
  // retry builds a fresh session; the abandoned abort keeps draining in the
425
511
  // background and its session dies with the run's teardown.
426
- await Promise.race([
512
+ await boundSessionAbort(
427
513
  session.abort().catch((err) => {
428
514
  logger.warn(`[ResearcherExecutor] Failed to abort researcher session ${id}:`, err);
429
515
  }),
430
- new Promise<void>((resolve) => {
431
- const t = setTimeout(() => {
432
- logger.warn(`[ResearcherExecutor] Researcher ${id} session abort did not settle within 10s; continuing cleanup`);
433
- resolve();
434
- }, 10_000);
435
- t.unref?.();
436
- }),
437
- ]);
516
+ () => logger.warn(`[ResearcherExecutor] Researcher ${id} session abort did not settle within 10s; continuing cleanup`),
517
+ );
438
518
  sessionService?.unregisterSession(researchId, id);
439
519
 
440
520
  // Restore the default thinking label now that the researcher is done.
@@ -23,6 +23,7 @@ import { logger } from '../logger.ts';
23
23
 
24
24
  import { resolveResearchModel } from '../core/llm/research-model-resolver.ts';
25
25
  import { getRuntimeForRegistry } from '../core/llm/model-registry-factory.ts';
26
+ import { boundSessionAbort } from './abort-utils.ts';
26
27
 
27
28
  export interface CreateResearcherSessionOptions {
28
29
  cwd: string;
@@ -158,27 +159,45 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
158
159
  thinkingLevel: config?.LLM_THINKING_LEVEL ?? 'off',
159
160
  });
160
161
 
161
- // Customize the agent thinking label for researchers (e.g. "Researcher 1.1")
162
- // so non-TUI surfaces can tell concurrent researchers apart. Skip this in the
163
- // research TUI (mode === 'tui'): the panel already shows every researcher, and
164
- // the host renders this label as dim/italic thinking text ABOVE the panel —
165
- // stray output the panel was meant to replace. Use the researcherId passed by
166
- // the caller (not regex on the prompt, since the ID is never templated in).
167
- if (extensionCtx.mode !== 'tui' && extensionCtx.hasUI && typeof extensionCtx.ui.setHiddenThinkingLabel === 'function') {
168
- extensionCtx.ui.setHiddenThinkingLabel(
169
- researcherId ? `Researcher ${researcherId}` : undefined
170
- );
162
+ // The session above is LIVE from here on, but the caller only receives it at
163
+ // the return below — a throw anywhere in this tail (a host UI callback, the
164
+ // validity check) would rethrow a session nobody owns, leaking it unaborted.
165
+ // Abort it best-effort (bounded: session.abort() awaits the in-flight call it
166
+ // cancels and may never settle) before rethrowing.
167
+ try {
168
+ // Customize the agent thinking label for researchers (e.g. "Researcher 1.1")
169
+ // so non-TUI surfaces can tell concurrent researchers apart. Skip this in the
170
+ // research TUI (mode === 'tui'): the panel already shows every researcher, and
171
+ // the host renders this label as dim/italic thinking text ABOVE the panel —
172
+ // stray output the panel was meant to replace. Use the researcherId passed by
173
+ // the caller (not regex on the prompt, since the ID is never templated in).
174
+ if (extensionCtx.mode !== 'tui' && extensionCtx.hasUI && typeof extensionCtx.ui.setHiddenThinkingLabel === 'function') {
175
+ extensionCtx.ui.setHiddenThinkingLabel(
176
+ researcherId ? `Researcher ${researcherId}` : undefined
177
+ );
178
+ }
179
+
180
+ logger.log(`[Researcher] Created session with model=${modelToUse?.id || 'unknown'}`);
181
+
182
+ if (!result || !result.session) {
183
+ throw new Error('Session creation returned invalid result');
184
+ }
185
+
186
+ sessionRef.session = result.session;
187
+
188
+ return { session: result.session, resolvedModel: modelToUse };
189
+ } catch (tailError) {
190
+ const session = result?.session;
191
+ if (session && typeof session.abort === 'function') {
192
+ await boundSessionAbort(
193
+ (async () => session.abort())().catch((abortErr) => {
194
+ logger.warn('[Researcher] Failed to abort never-returned session:', abortErr);
195
+ }),
196
+ () => logger.warn('[Researcher] Never-returned session abort did not settle within bound; continuing'),
197
+ );
198
+ }
199
+ throw tailError;
171
200
  }
172
-
173
- logger.log(`[Researcher] Created session with model=${modelToUse?.id || 'unknown'}`);
174
-
175
- if (!result || !result.session) {
176
- throw new Error('Session creation returned invalid result');
177
- }
178
-
179
- sessionRef.session = result.session;
180
-
181
- return { session: result.session, resolvedModel: modelToUse };
182
201
  } catch (error) {
183
202
  const errorMsg = error instanceof Error ? error.message : String(error);
184
203
  throw new Error(`Failed to create researcher session: ${errorMsg}`, {
@@ -520,6 +520,17 @@ export function endResearchSession(piSessionId: string | undefined, researchId:
520
520
  // but preserve steering messages if any remain (they might arrive between
521
521
  // the last research end and the next research start)
522
522
  if (state.order.length === 0 && state.panels.size === 0) {
523
+ // Drop steering messages this run already consumed ('active') — and any
524
+ // 'popped' remnants, which are likewise already resolved — before deciding
525
+ // whether to keep the session alive. Without this, a message the run that
526
+ // just ended already consumed stayed in the array and leaked into a later,
527
+ // unrelated run's steering (both getActiveSteeringMessages/getSteeringMessages
528
+ // reads and the before_agent_start injection gate), since nothing distinguishes
529
+ // "steering for THIS run" from "steering already applied to a finished run".
530
+ // Only messages still 'queued' (arrived in the gap, never attached to any run)
531
+ // are genuinely eligible for the gap-preservation this block performs.
532
+ state.steeringMessages = state.steeringMessages.filter(m => m.status === 'queued');
533
+
523
534
  // Only fully delete if there are no remaining steering messages
524
535
  const hasRemainingSteering = state.steeringMessages.length > 0;
525
536
  if (!hasRemainingSteering) {
package/src/sdk.ts CHANGED
@@ -28,6 +28,9 @@ import type {
28
28
  ResearchOptions
29
29
  } from './core/interfaces/orchestration-interfaces.ts';
30
30
  import type { IPlanningService } from './core/interfaces/planning-interfaces.ts';
31
+ import type { ResearchObserver, HeadlessObserverOptions } from './core/interfaces/observer-interfaces.ts';
32
+ import type { ResearchPlan } from './core/interfaces/research-plan-types.ts';
33
+ import { HeadlessObserver } from './orchestration/headless-observer.ts';
31
34
  import type { Model } from '@earendil-works/pi-ai';
32
35
  import { type ExtensionContext, ModelRegistry } from '@earendil-works/pi-coding-agent';
33
36
  import { logger, createLogger, setLogger } from './logger.ts';
@@ -94,6 +97,13 @@ export interface ResearcherOutcome {
94
97
  failureReasons: Record<string, string>;
95
98
  }
96
99
  let _lastResearcherOutcome: ResearcherOutcome | null = null;
100
+ // Monotonic SDK-lifetime counter, bumped by _doShutdown. A run left in flight across
101
+ // shutdownResearchSDK() settles AFTER the shutdown wiped the `_last*` accessors and
102
+ // cleared session metrics; its catch/finally would then repopulate them, so a
103
+ // re-initialized SDK reported a phantom run from the previous lifetime. Each run
104
+ // captures the generation at start and every post-run telemetry write is gated on it
105
+ // still matching.
106
+ let _sdkGeneration = 0;
97
107
 
98
108
  // Signal handler state — registered once, removed on clean shutdown.
99
109
  let _onSignal: ((signal: string) => void) | null = null;
@@ -489,6 +499,18 @@ export async function runDeepResearch(
489
499
  // here instead of vanishing into the never-read global tracker.
490
500
  const runTracker = new ErrorTracker();
491
501
 
502
+ // Stamp the run with the current shutdown generation: if shutdownResearchSDK()
503
+ // completes while this run is still in flight, _doShutdown bumps the generation
504
+ // and every telemetry write below is skipped — the shutdown wipe must win over
505
+ // an older-generation run settling late (see _sdkGeneration).
506
+ const runGeneration = _sdkGeneration;
507
+
508
+ // Planned-researcher count, captured DURING the run via the observer wrapper —
509
+ // it is unreadable from the planning service once runResearch() settles (the deep
510
+ // orchestrator clears that per-run state in its own finally first).
511
+ let plannedResearchers = 0;
512
+ const observer = wrapObserverForOutcomeCapture(options.observer, (count) => { plannedResearchers += count; });
513
+
492
514
  try {
493
515
  // Resolve the orchestrator INSIDE the try: getService() can throw (container
494
516
  // disposal race with shutdown, or an unregistered service), and if that throw
@@ -506,6 +528,9 @@ export async function runDeepResearch(
506
528
  depth: depth as ResearchDepth,
507
529
  complexity: complexity as 1 | 2 | 3,
508
530
  initialLinks: options.initialLinks,
531
+ // The caller's observer, wrapped so the SDK captures the planned-researcher
532
+ // events for getLastResearcherOutcome() (must land AFTER the options spread).
533
+ observer,
509
534
  // Forward the SDK's resolved config so initResearchSDK({ config }) overrides
510
535
  // actually reach the orchestrator and every downstream service. A per-call
511
536
  // options.config (if provided) takes precedence over the SDK-global config.
@@ -522,27 +547,40 @@ export async function runDeepResearch(
522
547
  // unbounded across calls (a long-lived SDK/benchmark process). runRegistry.observe() lands
523
548
  // it in this run's snapshot, matching the tool path's run-scoped emission.
524
549
  runRegistry.observe('research_manager_latency_ms', completedAt - researchStart, { depth: depthLabel, status: 'success', source: 'sdk' });
525
- _lastRunSummary = {
526
- runId: researchId, startedAt: researchStart, completedAt,
527
- durationMs: completedAt - researchStart, status: 'success', snapshot: runRegistry.getSnapshot(),
528
- };
529
- metrics.recordRunSummary(_lastRunSummary);
530
- _lastErrorReport = runTracker.getReport();
531
- logRunErrorSummary(_lastErrorReport, depthLabel, 'success');
532
- _lastResearcherOutcome = await captureResearcherOutcome(sessionId, researchId, _lastRunSummary.snapshot);
550
+ // Generation guard: skip every module-level telemetry write if a shutdown
551
+ // completed mid-run — the wipe must not be resurrected by a stale run.
552
+ if (runGeneration === _sdkGeneration) {
553
+ _lastRunSummary = {
554
+ runId: researchId, startedAt: researchStart, completedAt,
555
+ durationMs: completedAt - researchStart, status: 'success', snapshot: runRegistry.getSnapshot(),
556
+ };
557
+ metrics.recordRunSummary(_lastRunSummary);
558
+ _lastErrorReport = runTracker.getReport();
559
+ logRunErrorSummary(_lastErrorReport, depthLabel, 'success');
560
+ // Re-check the generation AFTER the await: the gate above was evaluated
561
+ // before it, so a shutdown completing during the capture would otherwise
562
+ // land this assignment after the wipe — a phantom outcome from a dead run.
563
+ const outcome = await captureResearcherOutcome(sessionId, researchId, _lastRunSummary.snapshot, plannedResearchers);
564
+ if (runGeneration === _sdkGeneration) _lastResearcherOutcome = outcome;
565
+ }
533
566
  return result;
534
567
  } catch (err) {
535
568
  const completedAt = Date.now();
536
569
  // Per-run scope (see the success path above) — avoids unbounded session-scope accumulation.
537
570
  runRegistry.observe('research_manager_latency_ms', completedAt - researchStart, { depth: depthLabel, status: 'error', source: 'sdk' });
538
- _lastRunSummary = {
539
- runId: researchId, startedAt: researchStart, completedAt,
540
- durationMs: completedAt - researchStart, status: 'error', snapshot: runRegistry.getSnapshot(),
541
- };
542
- metrics.recordRunSummary(_lastRunSummary);
543
- _lastErrorReport = runTracker.getReport();
544
- logRunErrorSummary(_lastErrorReport, depthLabel, 'error');
545
- _lastResearcherOutcome = await captureResearcherOutcome(sessionId, researchId, _lastRunSummary.snapshot);
571
+ // Generation guard — see the success path above.
572
+ if (runGeneration === _sdkGeneration) {
573
+ _lastRunSummary = {
574
+ runId: researchId, startedAt: researchStart, completedAt,
575
+ durationMs: completedAt - researchStart, status: 'error', snapshot: runRegistry.getSnapshot(),
576
+ };
577
+ metrics.recordRunSummary(_lastRunSummary);
578
+ _lastErrorReport = runTracker.getReport();
579
+ logRunErrorSummary(_lastErrorReport, depthLabel, 'error');
580
+ // Re-check after the await — see the success path above.
581
+ const outcome = await captureResearcherOutcome(sessionId, researchId, _lastRunSummary.snapshot, plannedResearchers);
582
+ if (runGeneration === _sdkGeneration) _lastResearcherOutcome = outcome;
583
+ }
546
584
  throw err;
547
585
  } finally {
548
586
  // Free the per-run PiSessionState that shouldStopResearch()/getFailedResearchers()
@@ -603,16 +641,73 @@ function logRunErrorSummary(report: ErrorReport | null, depthLabel: string, stat
603
641
  logger.warn(`[SDK] run ${status} with ${report.totalErrors} tracked error(s) across ${report.uniquePatterns} pattern(s) [${depthLabel}]${top ? ` — top: ${top}` : ''}`);
604
642
  }
605
643
 
644
+ /**
645
+ * Wrap the caller's observer so the SDK sees the planned-researcher events the deep
646
+ * orchestrator emits DURING the run. The count cannot be read back afterwards: the
647
+ * orchestrator's own finally runs cleanupResearchServices → clearPlanningState(researchId)
648
+ * BEFORE runResearch() resolves for the SDK, so a post-run getTotalResearchersPlanned()
649
+ * always sees 0. onPlanningSuccess (round 1) and onEvaluationDecision('delegate', …)
650
+ * (later rounds) fire at exactly the points the orchestrator increments that counter —
651
+ * it suppresses the delegate decision for a round-cap delegation it discards, so the
652
+ * captured total only counts researchers that were actually going to run.
653
+ *
654
+ * An { onProgress } options bag is normalized into a HeadlessObserver HERE rather than
655
+ * left to the orchestrator (which uses the same shape check): if the orchestrator wrapped
656
+ * the bag itself, the planning events would dispatch on ITS HeadlessObserver instance and
657
+ * never reach this capture layer. Mirrors makeSafeObserver's empty-target Proxy so a
658
+ * frozen caller observer can never trip the Proxy own-property invariant.
659
+ */
660
+ function wrapObserverForOutcomeCapture(
661
+ observer: ResearchObserver | HeadlessObserverOptions | undefined,
662
+ onPlanned: (count: number) => void,
663
+ ): ResearchObserver {
664
+ const base: ResearchObserver | undefined =
665
+ observer && typeof (observer as HeadlessObserverOptions).onProgress === 'function' && !(observer instanceof HeadlessObserver)
666
+ ? new HeadlessObserver(observer as HeadlessObserverOptions)
667
+ : (observer as ResearchObserver | undefined);
668
+
669
+ return new Proxy({} as ResearchObserver, {
670
+ get(_target, prop) {
671
+ if (prop === 'onPlanningSuccess') {
672
+ return (plan: ResearchPlan) => {
673
+ onPlanned(plan?.researchers?.length ?? 0);
674
+ base?.onPlanningSuccess?.(plan);
675
+ };
676
+ }
677
+ if (prop === 'onEvaluationDecision') {
678
+ return (action: 'synthesize' | 'delegate', plan?: ResearchPlan, round?: number) => {
679
+ if (action === 'delegate') onPlanned(plan?.researchers?.length ?? 0);
680
+ base?.onEvaluationDecision?.(action, plan, round);
681
+ };
682
+ }
683
+ if (!base) return undefined;
684
+ const value = Reflect.get(base, prop, base);
685
+ return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(base) : value;
686
+ },
687
+ has(_target, prop) {
688
+ if (prop === 'onPlanningSuccess' || prop === 'onEvaluationDecision') return true;
689
+ return base ? Reflect.has(base, prop) : false;
690
+ },
691
+ });
692
+ }
693
+
606
694
  /**
607
695
  * Capture how many researchers were planned/launched/failed for this run, BEFORE
608
696
  * the caller's `finally` block frees the PiSessionState (endResearchSession) that
609
697
  * getFailedResearchers()/getResearcherFailureReasons() read from. Best-effort:
610
698
  * this is instrumentation only and must never affect the run's own result.
611
699
  */
612
- async function captureResearcherOutcome(sessionId: string, researchId: string, snapshot: IMetricsSnapshot): Promise<ResearcherOutcome | null> {
700
+ async function captureResearcherOutcome(sessionId: string, researchId: string, snapshot: IMetricsSnapshot, plannedFromObserver: number): Promise<ResearcherOutcome | null> {
613
701
  try {
614
- const planningService = await getService<IPlanningService>(ServiceNames.PLANNING, undefined, globalContainer!);
615
- const planned = planningService.getTotalResearchersPlanned(researchId);
702
+ // Deep runs: the observer-captured count is authoritative — the orchestrator's
703
+ // finally already cleared the planning service's per-run entry by the time the SDK
704
+ // regains control (see wrapObserverForOutcomeCapture). The service read stays as a
705
+ // fallback for any path that never emits the planning events.
706
+ let planned = plannedFromObserver;
707
+ if (planned === 0) {
708
+ const planningService = await getService<IPlanningService>(ServiceNames.PLANNING, undefined, globalContainer!);
709
+ planned = planningService.getTotalResearchersPlanned(researchId);
710
+ }
616
711
  const launched = extractRunStats(snapshot)?.researchersLaunched ?? 0;
617
712
  const failed = getFailedResearchers(sessionId, researchId);
618
713
  return {
@@ -825,6 +920,14 @@ export async function shutdownResearchSDK(): Promise<void> {
825
920
  async function _doShutdown(): Promise<void> {
826
921
  logger.log('[SDK] Shutting down Research SDK...');
827
922
 
923
+ // Invalidate the pending telemetry writes of any run still in flight FIRST —
924
+ // before runCleanup and the other long awaits below. The invariant: a run
925
+ // settling at ANY point after shutdown begins fails the generation gate. Bumping
926
+ // at the end instead left the whole teardown (cleanup, clearSession, service
927
+ // disposal) as a window in which a settling run passed the gate and wrote a
928
+ // phantom run-summary into the just-cleared session metrics.
929
+ _sdkGeneration++;
930
+
828
931
  const errors: Error[] = [];
829
932
 
830
933
  try {
@@ -886,6 +989,9 @@ async function _doShutdown(): Promise<void> {
886
989
  _lastRunSummary = null;
887
990
  _lastErrorReport = null;
888
991
  _lastResearcherOutcome = null;
992
+ // (No generation bump here — it happened at the TOP of this function, so any
993
+ // run settling during the teardown above already failed the gate and could not
994
+ // repopulate the `_last*` accessors or session metrics being wiped.)
889
995
  // The module-level config cache survives the globals above. Without this, a
890
996
  // shutdown → env/config-file change → re-init cycle in one process silently
891
997
  // reuses the first init's config. (The CLI/skill are unaffected — they pass
@@ -10,6 +10,7 @@ import type { Vulnerability, CisaKevResult } from './types.ts';
10
10
  import { createTimeoutSignal, retryWithBackoff, isTransientError } from '../web-research/retry-utils.ts';
11
11
  import { CircuitBreaker } from '../utils/circuit-breaker.ts';
12
12
  import { metrics } from '../utils/metrics.ts';
13
+ import { readJsonCapped, BodyTooLargeError, MAX_BULK_JSON_BODY_BYTES } from '../utils/http-body.ts';
13
14
 
14
15
  const CISA_KEV_URL = 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json';
15
16
 
@@ -183,10 +184,16 @@ export async function searchCisaKev(
183
184
 
184
185
  let data: unknown;
185
186
  try {
186
- data = await response.json();
187
- } catch {
187
+ // The KEV feed is the whole catalogue in one document — legitimately tens of
188
+ // megabytes and growing — so it reads against the bulk cap, not the default.
189
+ data = await readJsonCapped(response, MAX_BULK_JSON_BODY_BYTES);
190
+ } catch (err) {
191
+ if (err instanceof BodyTooLargeError) {
192
+ metrics.increment('cisa_kev_errors_total', 1, { error_type: 'body_too_large' });
193
+ throw err;
194
+ }
188
195
  metrics.increment('cisa_kev_errors_total', 1, { error_type: 'malformed_json' });
189
- throw new Error('CISA KEV feed returned a malformed JSON response.');
196
+ throw new Error('CISA KEV feed returned a malformed JSON response.', { cause: err });
190
197
  }
191
198
 
192
199
  // CISA KEV format is an array of vulnerability objects or nested in a response object