@lincoln504/pi-research 1.2.0 → 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 (81) hide show
  1. package/dist/cli.mjs +1334 -750
  2. package/dist/thread-worker.mjs +194 -136
  3. package/docs/ARCHITECTURE.md +1 -1
  4. package/docs/CHANGELOG.md +114 -16
  5. package/package.json +1 -1
  6. package/scripts/cleanup.cjs +64 -2
  7. package/skills/pi-research/SKILL.md +1 -1
  8. package/src/cleanup/research-cleanup.ts +12 -4
  9. package/src/cli.ts +228 -70
  10. package/src/config.ts +78 -2
  11. package/src/core/interfaces/health-check-interfaces.ts +3 -39
  12. package/src/core/interfaces/planning-interfaces.ts +8 -0
  13. package/src/core/interfaces/service-names.ts +0 -1
  14. package/src/core/interfaces/state-manager-interfaces.ts +10 -2
  15. package/src/core/llm/agentic-repair.ts +14 -0
  16. package/src/core/planning-service.ts +43 -1
  17. package/src/core/service-initialization.ts +6 -14
  18. package/src/core/service-interfaces.ts +1 -1
  19. package/src/healthcheck/index.ts +7 -2
  20. package/src/infrastructure/browser/browser-client.ts +64 -9
  21. package/src/infrastructure/browser/browser-server.ts +24 -7
  22. package/src/infrastructure/browser/browser-task-scheduler.ts +58 -12
  23. package/src/infrastructure/browser/cleanup-utils.ts +50 -2
  24. package/src/infrastructure/browser/client-agent.ts +6 -6
  25. package/src/infrastructure/browser/config.ts +14 -3
  26. package/src/infrastructure/browser/scheduler-factory.ts +18 -3
  27. package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
  28. package/src/infrastructure/browser/thread-worker-types.ts +3 -1
  29. package/src/infrastructure/browser/thread-worker.mjs +194 -136
  30. package/src/infrastructure/browser/worker-pool-manager.ts +7 -2
  31. package/src/infrastructure/embedding/embedding-factory.ts +35 -6
  32. package/src/infrastructure/embedding/embedding-server.ts +40 -7
  33. package/src/infrastructure/file-lock-service.ts +72 -2
  34. package/src/infrastructure/knowledge-store-service.ts +48 -3
  35. package/src/infrastructure/process-lifecycle-service.ts +28 -5
  36. package/src/infrastructure/research-run-semaphore.ts +37 -3
  37. package/src/infrastructure/state/state-browser-api.ts +5 -2
  38. package/src/infrastructure/state/state-browser-manager.ts +15 -1
  39. package/src/infrastructure/state/state-manager-service.ts +4 -4
  40. package/src/infrastructure/state/state-manager.ts +15 -3
  41. package/src/knowledge/chunker.ts +88 -1
  42. package/src/knowledge/embedder.ts +136 -43
  43. package/src/knowledge/store.ts +66 -12
  44. package/src/logger.ts +5 -1
  45. package/src/observers/research-observer-impl.ts +31 -0
  46. package/src/orchestration/abort-utils.ts +60 -0
  47. package/src/orchestration/deep-research-orchestrator.ts +12 -2
  48. package/src/orchestration/headless-observer.ts +8 -0
  49. package/src/orchestration/research-orchestration-service.ts +63 -11
  50. package/src/orchestration/research-session-service.ts +37 -11
  51. package/src/orchestration/researcher-executor.ts +100 -20
  52. package/src/orchestration/researcher.ts +39 -20
  53. package/src/orchestration/session-state.ts +11 -0
  54. package/src/sdk.ts +125 -19
  55. package/src/security/index.ts +51 -30
  56. package/src/security/nvd-client.ts +7 -2
  57. package/src/skill-install/skill-installer.ts +18 -2
  58. package/src/stackexchange/index.ts +20 -5
  59. package/src/stackexchange/output/table.ts +7 -2
  60. package/src/stackexchange/rest-client.ts +35 -1
  61. package/src/tools/research-tool-definition.ts +8 -2
  62. package/src/tools/scrape.ts +48 -25
  63. package/src/tools/search.ts +10 -1
  64. package/src/tools/security.ts +68 -7
  65. package/src/tui/research-health.ts +18 -2
  66. package/src/utils/circuit-breaker.ts +9 -2
  67. package/src/utils/disk-space-checker.ts +11 -6
  68. package/src/utils/json-utils.ts +116 -76
  69. package/src/utils/llm-usage.ts +17 -1
  70. package/src/utils/log-rotation.ts +42 -10
  71. package/src/utils/log-utils.ts +40 -0
  72. package/src/utils/metrics-summary.ts +27 -9
  73. package/src/utils/stdio-capture.ts +63 -22
  74. package/src/utils/text-utils.ts +8 -1
  75. package/src/utils/tool-usage-tracker.ts +9 -2
  76. package/src/web-research/browser-search.ts +26 -7
  77. package/src/web-research/scraper-utils.ts +13 -0
  78. package/src/web-research/search.ts +6 -2
  79. package/src/web-research/types.ts +4 -2
  80. package/src/web-research/web-scraper.ts +65 -4
  81. package/src/core/health-check-service.ts +0 -265
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "A set of web research tools (search, scrape, and others) and research agent system that uses them safely and efficiently.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  const { spawnSync } = require('child_process');
19
- const { rmSync, existsSync, lstatSync, readlinkSync, readFileSync, writeFileSync, unlinkSync } = require('fs');
19
+ const { rmSync, existsSync, lstatSync, readdirSync, readlinkSync, readFileSync, writeFileSync, unlinkSync } = require('fs');
20
20
  const os = require('os');
21
21
  const path = require('path');
22
22
 
@@ -181,9 +181,71 @@ const rawDirName = process.env.PI_RESEARCH_CONFIG_DIR_NAME;
181
181
  const dirName = rawDirName && !rawDirName.includes('/') && !rawDirName.includes('\\') && rawDirName !== '.' && rawDirName !== '..'
182
182
  ? rawDirName
183
183
  : '.pi';
184
+
185
+ /**
186
+ * Is this path PROVABLY equivalent to the layout the runtime itself would have
187
+ * constructed by default (`<dirName>/research`)? Wholesale rm of a state-dir
188
+ * override is only safe when the answer is yes: PI_RESEARCH_STATE_DIR may point
189
+ * at a SHARED directory, and rm'ing the override itself (unlike the cache sweep,
190
+ * which is namespaced via join(cacheHome, 'pi-research')) deleted everything the
191
+ * user kept beside our state.
192
+ *
193
+ * Deliberately NOT a bare "does any segment literally say pi-research" check —
194
+ * that matches naming convention, not ownership. A user is entirely free to
195
+ * point the override at, say, `~/agents-state/pi-research/` while keeping other
196
+ * tools' notes alongside it in the same folder (a natural way to organize
197
+ * several tools' state by name); a substring match would treat that shared
198
+ * folder as exclusively ours and wholesale-delete it. Only the exact default
199
+ * layout is something WE constructed and can therefore prove is ours.
200
+ */
201
+ function isPiResearchNamespaced(p) {
202
+ const segs = p.split(/[\\/]+/).filter(Boolean);
203
+ for (let i = 0; i < segs.length - 1; i++) {
204
+ if (segs[i] === dirName && segs[i + 1] === 'research') return true;
205
+ }
206
+ return false;
207
+ }
208
+
209
+ /**
210
+ * Targeted state sweep for a non-namespaced override dir: delete ONLY the
211
+ * entries the runtime actually creates there and leave everything else — and
212
+ * the directory itself — untouched. The entry list mirrors the writers:
213
+ * research-state.json + research-state-<hex>.tmp (state-manager)
214
+ * .locks/, backups/ (state-path-configuration)
215
+ * project-settings.json + its .lock / .tmp. / .lock.stale- companions (config.ts)
216
+ * research-run-slot-<i>.lock (research-run-semaphore)
217
+ * Best-effort; never throws.
218
+ */
219
+ function removeOwnedStateEntries(dir) {
220
+ if (!dir || !existsSync(dir)) return;
221
+ let entries;
222
+ try { entries = readdirSync(dir); } catch { return; }
223
+ const owned = (name) =>
224
+ name === 'research-state.json' ||
225
+ (name.startsWith('research-state-') && name.endsWith('.tmp')) ||
226
+ name === '.locks' ||
227
+ name === 'backups' ||
228
+ name === 'project-settings.json' ||
229
+ name.startsWith('project-settings.json.') ||
230
+ /^research-run-slot-\d+\.lock$/.test(name);
231
+ for (const name of entries) {
232
+ if (!owned(name)) continue;
233
+ try {
234
+ rmSync(path.join(dir, name), { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
235
+ } catch (error) {
236
+ console.warn(`pi-research: could not remove state entry ${path.join(dir, name)}: ${error instanceof Error ? error.message : String(error)}`);
237
+ }
238
+ }
239
+ console.log(`pi-research: removed pi-research state entries from ${dir} (directory preserved — not a pi-research-namespaced path).`);
240
+ }
241
+
184
242
  const stateDir = stateOverride && path.isAbsolute(stateOverride)
185
243
  ? stateOverride
186
244
  : path.join(os.homedir(), dirName, 'research', 'state');
187
- removeOwnedScratchDir('state dir', stateDir);
245
+ if (stateOverride && path.isAbsolute(stateOverride) && !isPiResearchNamespaced(stateDir)) {
246
+ removeOwnedStateEntries(stateDir);
247
+ } else {
248
+ removeOwnedScratchDir('state dir', stateDir);
249
+ }
188
250
 
189
251
  process.exit(0);
@@ -2,7 +2,7 @@
2
2
  name: pi-research
3
3
  description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
4
4
  license: MIT
5
- metadata: { "author": "Lincoln Deen", "version": "1.2.0", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.3.0", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
@@ -82,11 +82,19 @@ export function createCleanupFunction(
82
82
  const { resetLogger } = await import('../logger.ts');
83
83
  resetLogger(researchId);
84
84
 
85
- // FIX (#10): Clear session circuit breaker to prevent unbounded map growth.
85
+ // FIX (#10): Clear the session's browser circuit breaker to prevent unbounded
86
+ // map growth. Breakers are keyed by the plain researchId — createResearchRunId()
87
+ // generates it (`run-<8 hex>`) independently of piSessionId in
88
+ // research-tool-definition.ts, and tools/scrape.ts / tools/search.ts thread that
89
+ // same bare researchId into getBrowserCircuitBreaker() as BrowserTask.sessionId.
90
+ // (The `${piSessionId}-<8 hex>` shape this comment used to assume is produced
91
+ // only by generateSessionId(), which has no production caller.) This duplicates
92
+ // the clearSessionCircuitBreaker(targetId) call research-orchestration-service.ts
93
+ // already makes in its own finally block on the normal run-completion path; it
94
+ // matters here on paths where this cleanup runs alone, e.g. a throw before
95
+ // orchestrator.run() is ever reached.
86
96
  const { clearSessionCircuitBreaker } = await import('../infrastructure/browser/browser-error-utils.ts');
87
- if (piSessionId) {
88
- clearSessionCircuitBreaker(piSessionId);
89
- }
97
+ clearSessionCircuitBreaker(researchId);
90
98
 
91
99
  const activePanels = getPiActivePanels(piSessionId);
92
100
  if (activePanels.length === 0) {
package/src/cli.ts CHANGED
@@ -209,11 +209,24 @@ function makeProgressObserver(): HeadlessObserverOptions {
209
209
  // ---------------------------------------------------------------------------
210
210
 
211
211
 
212
+ /**
213
+ * Thrown by bridgeConfigEnv when an EXPLICIT `--config` file is missing or
214
+ * unreadable. The ambient layers (config.env, cli.env) are optional overlays and
215
+ * stay tolerant — but a file the user named on this exact invocation silently
216
+ * not applying meant a typo'd `--config /path/ci.evn` ran on ambient config and
217
+ * reported success. main() maps this to EXIT.CONFIG for every command that
218
+ * accepts --config.
219
+ */
220
+ export class ConfigFileError extends Error {}
221
+
212
222
  /**
213
223
  * Bridge the CLI's config files into process.env for keys not already set in the
214
224
  * real environment. Layers the base `~/.pi/research/config.env` then the CLI's
215
- * own `~/.pi/research/cli.env` overlay (overlay wins), or a `--config <path>`
216
- * override in place of the base.
225
+ * own `~/.pi/research/cli.env` overlay (overlay wins). With an explicit
226
+ * `--config <path>` the NAMED file layers on top of cli.env instead (highest
227
+ * file precedence); it does not replace the base — getConfig() still reads
228
+ * config.env as a lower-precedence file layer, so base keys the named file does
229
+ * not set still apply, and real environment variables always win.
217
230
  *
218
231
  * Why: auth values (PI_RESEARCH_API_KEY / _PROVIDER) are read directly from
219
232
  * process.env, NOT from the typed Config, so a key placed only in a config file
@@ -234,18 +247,37 @@ export function bridgeConfigEnv(explicitConfigPath?: string): { path: string; lo
234
247
  // default base, not the cli.env overlay). Project-scoped keys are only
235
248
  // bridged for these — see below.
236
249
  const fromExplicitBase = new Set<string>();
237
- for (const filePath of [basePath, cliOverlayPath]) {
250
+ // Layer order decides the winner (later merge wins). Default: base then cli.env
251
+ // (overlay wins). With an explicit --config the NAMED file merges last: a file
252
+ // the user passed on this exact invocation is the most specific intent, and the
253
+ // ambient cli.env overlay silently overriding it re-created the very failure
254
+ // this bridge exists to fix (a key set in `--config ci.env` simply not applying).
255
+ const layers = explicitConfigPath !== undefined
256
+ ? [cliOverlayPath, basePath]
257
+ : [basePath, cliOverlayPath];
258
+ for (const filePath of layers) {
259
+ // Only the EXPLICIT --config file fails loudly (see ConfigFileError above);
260
+ // the ambient layers keep their missing/unreadable tolerance unchanged.
261
+ const isExplicit = explicitConfigPath !== undefined && filePath === basePath;
238
262
  try {
239
- if (!fs.existsSync(filePath)) continue;
263
+ if (!fs.existsSync(filePath)) {
264
+ if (isExplicit) throw new ConfigFileError(`--config file not found: ${filePath}`);
265
+ continue;
266
+ }
240
267
  const parsed = parseDotEnv(fs.readFileSync(filePath, 'utf-8'));
241
268
  Object.assign(merged, parsed);
242
- for (const k of Object.keys(parsed)) {
243
- if (explicitConfigPath !== undefined && filePath === basePath) fromExplicitBase.add(k);
244
- else fromExplicitBase.delete(k);
269
+ if (isExplicit) {
270
+ for (const k of Object.keys(parsed)) fromExplicitBase.add(k);
245
271
  }
246
272
  loaded = true;
247
- } catch {
248
- // ignore unreadable file; fall through to the next
273
+ } catch (err) {
274
+ if (err instanceof ConfigFileError) throw err;
275
+ if (isExplicit) {
276
+ throw new ConfigFileError(
277
+ `--config file could not be read: ${filePath} (${err instanceof Error ? err.message : String(err)})`,
278
+ );
279
+ }
280
+ // ignore unreadable ambient file; fall through to the next
249
281
  }
250
282
  }
251
283
  for (const [k, v] of Object.entries(merged)) {
@@ -514,7 +546,7 @@ interface ResearchArgs {
514
546
  * Research subcommand. Initializes the SDK, runs deep/quick research, prints the
515
547
  * markdown report to stdout, and always shuts down (even on failure).
516
548
  */
517
- async function cmdResearch(args: ResearchArgs): Promise<number> {
549
+ export async function cmdResearch(args: ResearchArgs): Promise<number> {
518
550
  // Pre-flight with the per-run --model flag folded in: it satisfies the
519
551
  // model-required check and steers the provider-aware key detection.
520
552
  const det = await detectCredentials(args.model);
@@ -587,6 +619,18 @@ async function cmdResearch(args: ResearchArgs): Promise<number> {
587
619
  }
588
620
  }
589
621
 
622
+ // DeepResearchOrchestrator.run() does not throw on cancellation once at least
623
+ // one researcher has produced a report — it returns a fallback synthesis
624
+ // through its normal return path (synthesisService.hasReports branch; see
625
+ // deep-research-orchestrator.ts ~line 584-618). Left unchecked, a Ctrl-C
626
+ // mid-run that still yields a partial report would fall straight through to
627
+ // the unconditional success handling below and report ok:true / exit 0 —
628
+ // indistinguishable from an ordinary completed run. Mirrors reportError's
629
+ // own classification (same exitCodeForSignal formula) for the throwing case.
630
+ if (cancellationRequested) {
631
+ exit = cancellationSignal ? exitCodeForSignal(cancellationSignal) : EXIT.CANCELLED;
632
+ }
633
+
590
634
  if (args.json) {
591
635
  // Surface the internal quality/health signals that were previously log-only
592
636
  // (ISSUES-ANALYSIS-2026-07-09.md, Symptom 4): a caller reading only stdout/JSON
@@ -599,12 +643,20 @@ async function cmdResearch(args: ResearchArgs): Promise<number> {
599
643
  depth,
600
644
  report,
601
645
  ...(reportPath ? { reportPath } : {}),
646
+ // The run DID produce a real, complete report — ok stays true. `cancelled`
647
+ // is the qualifier a --json consumer needs to tell "user stopped it, here's
648
+ // what we had" apart from an ordinary finish.
649
+ ...(cancellationRequested ? { cancelled: true } : {}),
602
650
  ...(outcome ? { researchers: { planned: outcome.planned, launched: outcome.launched, succeeded: outcome.succeeded, failed: outcome.failed } } : {}),
603
651
  ...(errorReport && errorReport.totalErrors > 0 ? {
604
652
  trackedErrors: { total: errorReport.totalErrors, uniquePatterns: errorReport.uniquePatterns },
605
653
  } : {}),
606
654
  }));
607
655
  } else {
656
+ // Still a real, useful report — print it as before. The note goes to stderr
657
+ // (alongside the other lifecycle lines) so it never contaminates the report
658
+ // text a plain-mode caller pipes from stdout.
659
+ if (cancellationRequested) toStderr('[pi-research] run was cancelled — showing the partial report.\n');
608
660
  toStdout(report.endsWith('\n') ? report : report + '\n');
609
661
  }
610
662
  } catch (err) {
@@ -621,7 +673,7 @@ async function cmdResearch(args: ResearchArgs): Promise<number> {
621
673
  * result; `found: 'no'`/`'maybe'` tells the caller to follow up with live
622
674
  * research.
623
675
  */
624
- async function cmdKnowledge(queries: string[], json?: boolean): Promise<number> {
676
+ export async function cmdKnowledge(queries: string[], json?: boolean): Promise<number> {
625
677
  const det = await detectCredentials();
626
678
  if (det.problem) {
627
679
  if (json) { toStdout(pretty({ ok: false, error: det.problem, exitCode: EXIT.CONFIG })); return EXIT.CONFIG; }
@@ -636,7 +688,7 @@ async function cmdKnowledge(queries: string[], json?: boolean): Promise<number>
636
688
  'Knowledge store is disabled (PI_RESEARCH_KNOWLEDGE_STORE_MODE=none). ' +
637
689
  "Set it to 'project' or 'global' to enable local knowledge search.\n\n" +
638
690
  `Configure in:\n ${paths.configEnv}\n ${paths.cliIfaceEnv} (cli overlay)\n or via PI_RESEARCH_KNOWLEDGE_STORE_MODE env var`;
639
- if (json) toStdout(pretty({ found: 'no', text: msg, configured: false }));
691
+ if (json) toStdout(pretty({ ok: true, found: 'no', text: msg, configured: false }));
640
692
  else toStderr(`\n[pi-research] ${msg}\n`);
641
693
  return EXIT.CONFIG;
642
694
  }
@@ -660,7 +712,10 @@ async function cmdKnowledge(queries: string[], json?: boolean): Promise<number>
660
712
  }
661
713
 
662
714
  if (json) {
663
- toStdout(pretty(result));
715
+ // `ok: true` makes `ok` a reliable success/failure discriminant across every
716
+ // knowledge JSON variant (disabled-store above, reportError's ok:false below,
717
+ // and this success shape) — additive only, every existing field is unchanged.
718
+ toStdout(pretty({ ok: true, ...result }));
664
719
  } else {
665
720
  toStdout((result.text.endsWith('\n') ? result.text : result.text + '\n'));
666
721
  }
@@ -715,51 +770,60 @@ async function cmdStatus(json?: boolean): Promise<number> {
715
770
  * setting on the user's behalf without hand-editing the locked registry JSON. A machine-wide
716
771
  * default is set instead via the PI_RESEARCH_KNOWLEDGE_STORE_MODE env var (per-run) or config.env.
717
772
  */
718
- async function cmdKnowledgeConfig(kc: NonNullable<ParsedArgs['knowledgeConfig']>): Promise<number> {
773
+ export async function cmdKnowledgeConfig(kc: NonNullable<ParsedArgs['knowledgeConfig']>): Promise<number> {
719
774
  const cwd = process.cwd();
720
775
 
721
- if (kc.action === 'set') {
722
- const before = getConfig(cwd, 'cli').KNOWLEDGE_STORE_MODE;
723
- const cfg = { ...getConfig(cwd, 'cli'), KNOWLEDGE_STORE_MODE: kc.mode! };
724
- // Persist ONLY the knowledge-store mode for this directory — not the whole local-key set —
725
- // so an unrelated per-directory DEFAULT_RESEARCH_DEPTH isn't frozen as a side effect.
726
- saveConfig(cfg, 'local', cwd, ['PI_RESEARCH_KNOWLEDGE_STORE_MODE']); // per-directory registry write
727
- resetConfig(); // drop the cached config so `after` re-resolves from disk
776
+ // Wrapped like cmdKnowledge's own try/catch → reportError: without this, a
777
+ // thrown error (e.g. saveConfig failing to write the registry) escaped all the
778
+ // way to main()'s top-level catch, which ALWAYS writes plain text to stderr —
779
+ // silently breaking the documented `--json: Emit a JSON object` contract for
780
+ // this one subcommand.
781
+ try {
782
+ if (kc.action === 'set') {
783
+ const before = getConfig(cwd, 'cli').KNOWLEDGE_STORE_MODE;
784
+ const cfg = { ...getConfig(cwd, 'cli'), KNOWLEDGE_STORE_MODE: kc.mode! };
785
+ // Persist ONLY the knowledge-store mode for this directory — not the whole local-key set —
786
+ // so an unrelated per-directory DEFAULT_RESEARCH_DEPTH isn't frozen as a side effect.
787
+ saveConfig(cfg, 'local', cwd, ['PI_RESEARCH_KNOWLEDGE_STORE_MODE']); // per-directory registry write
788
+ resetConfig(); // drop the cached config so `after` re-resolves from disk
789
+ const info = describeKnowledgeStoreMode(cwd, 'cli');
790
+ // The write always persists to the registry, but a real env var out-ranks the registry, so
791
+ // the EFFECTIVE mode can still differ from what was requested. Surface that instead of
792
+ // silently reporting a no-op.
793
+ const overridden = info.mode !== kc.mode;
794
+ if (kc.json) {
795
+ toStdout(pretty({ command: 'knowledge-config', action: 'set', requested: kc.mode, previous: before, saved: true, effectiveOverriddenBy: overridden ? info.origin : null, ...info, cwd }));
796
+ return EXIT.OK;
797
+ }
798
+ toStdout(
799
+ `knowledge store mode for this directory: ${before} -> ${kc.mode} (saved per-directory)\n` +
800
+ ` scope: ${kc.mode === 'none' ? 'disabled here' : kc.mode === 'project' ? 'this directory only' : 'shared across all directories'}\n` +
801
+ ` saved: per-directory (${cwd})\n` +
802
+ ` db dir: ${info.dbDir}\n` +
803
+ (overridden
804
+ ? `\nNOTE: the effective mode here is still '${info.mode}' because a higher-precedence ${info.origin} overrides it. Clear that source for this setting to take effect.\n`
805
+ : ''),
806
+ );
807
+ return EXIT.OK;
808
+ }
809
+
810
+ // show
728
811
  const info = describeKnowledgeStoreMode(cwd, 'cli');
729
- // The write always persists to the registry, but a real env var out-ranks the registry, so
730
- // the EFFECTIVE mode can still differ from what was requested. Surface that instead of
731
- // silently reporting a no-op.
732
- const overridden = info.mode !== kc.mode;
733
812
  if (kc.json) {
734
- toStdout(pretty({ command: 'knowledge-config', action: 'set', requested: kc.mode, previous: before, saved: true, effectiveOverriddenBy: overridden ? info.origin : null, ...info, cwd }));
813
+ toStdout(pretty({ command: 'knowledge-config', action: 'show', ...info, cwd }));
735
814
  return EXIT.OK;
736
815
  }
737
816
  toStdout(
738
- `knowledge store mode for this directory: ${before} -> ${kc.mode} (saved per-directory)\n` +
739
- ` scope: ${kc.mode === 'none' ? 'disabled here' : kc.mode === 'project' ? 'this directory only' : 'shared across all directories'}\n` +
740
- ` saved: per-directory (${cwd})\n` +
817
+ `knowledge store mode: ${info.mode} (source: ${info.origin})\n` +
818
+ ` scope: ${info.mode === 'none' ? 'disabled here' : info.mode === 'project' ? 'this directory only' : 'shared across all directories'}\n` +
741
819
  ` db dir: ${info.dbDir}\n` +
742
- (overridden
743
- ? `\nNOTE: the effective mode here is still '${info.mode}' because a higher-precedence ${info.origin} overrides it. Clear that source for this setting to take effect.\n`
744
- : ''),
820
+ `\nchange it for THIS directory: ${BINARY_NAME} knowledge-config set <none|project|global>\n` +
821
+ `change the machine-wide default: set PI_RESEARCH_KNOWLEDGE_STORE_MODE=<mode> in ${resolvedConfigPaths().configEnv}\n`,
745
822
  );
746
823
  return EXIT.OK;
824
+ } catch (err) {
825
+ return reportError(err, 'knowledge-config', kc.json);
747
826
  }
748
-
749
- // show
750
- const info = describeKnowledgeStoreMode(cwd, 'cli');
751
- if (kc.json) {
752
- toStdout(pretty({ command: 'knowledge-config', action: 'show', ...info, cwd }));
753
- return EXIT.OK;
754
- }
755
- toStdout(
756
- `knowledge store mode: ${info.mode} (source: ${info.origin})\n` +
757
- ` scope: ${info.mode === 'none' ? 'disabled here' : info.mode === 'project' ? 'this directory only' : 'shared across all directories'}\n` +
758
- ` db dir: ${info.dbDir}\n` +
759
- `\nchange it for THIS directory: ${BINARY_NAME} knowledge-config set <none|project|global>\n` +
760
- `change the machine-wide default: set PI_RESEARCH_KNOWLEDGE_STORE_MODE=<mode> in ${resolvedConfigPaths().configEnv}\n`,
761
- );
762
- return EXIT.OK;
763
827
  }
764
828
 
765
829
  // ---------------------------------------------------------------------------
@@ -1058,21 +1122,25 @@ COMMANDS
1058
1122
  --model <provider/id> Model for this run (outranks PI_RESEARCH_MODEL; satisfies the required-model check).
1059
1123
  --exclude-tools <a,b> Disable internal tools (e.g. security_search,stackexchange).
1060
1124
  --initial-links <url ...> Seed URLs to investigate first; requires a query (-- ends options).
1061
- --config <path> Use this config file instead of the base config.env.
1125
+ --config <path> Layer this config file on top (highest file precedence; base
1126
+ config.env keys it does not set still apply; env vars still win).
1062
1127
  --json Emit a JSON object instead of a markdown report.
1063
1128
 
1064
1129
  knowledge "<q1>" ["<q2>" ...] Search local knowledge store before live research (up to 5 queries).
1065
- --config <path> Use this config file instead of the base config.env.
1130
+ --config <path> Layer this config file on top (highest file precedence; base
1131
+ config.env keys it does not set still apply; env vars still win).
1066
1132
  --json Emit a JSON object.
1067
1133
 
1068
1134
  knowledge-config [show] Show the knowledge-store mode for this directory + its source.
1069
- --config <path> Use this config file instead of the base config.env.
1135
+ --config <path> Layer this config file on top (highest file precedence; base
1136
+ config.env keys it does not set still apply; env vars still win).
1070
1137
  --json Emit a JSON object.
1071
1138
  knowledge-config set <mode> Set the mode for THIS directory (none | project | global);
1072
1139
  persisted per-directory. Takes effect on the next run.
1073
1140
 
1074
1141
  status Show detected config, model/key, and readiness.
1075
- --config <path> Use this config file instead of the base config.env.
1142
+ --config <path> Layer this config file on top (highest file precedence; base
1143
+ config.env keys it does not set still apply; env vars still win).
1076
1144
  --json Emit a JSON object.
1077
1145
 
1078
1146
  skill [status] Show where the agent skill is installed across your coding agents.
@@ -1118,6 +1186,20 @@ EXIT CODES
1118
1186
  `;
1119
1187
  }
1120
1188
 
1189
+ /**
1190
+ * Read a value-taking option's argument, rejecting the next FLAG as a value.
1191
+ * Truthiness alone let `--model --json` run a full research session against the
1192
+ * literal model "--json" (failing minutes-deep at exit 70 with the --json output
1193
+ * contract silently dropped) instead of failing fast at exit 64.
1194
+ */
1195
+ function takeOptionValue(rest: (string | undefined)[], valueIndex: number, flag: string): string {
1196
+ const v = rest[valueIndex];
1197
+ if (v === undefined || v.startsWith('--')) {
1198
+ throw new UsageError(`${flag} requires a value.`);
1199
+ }
1200
+ return v;
1201
+ }
1202
+
1121
1203
  export function parseArgs(argv: string[]): ParsedArgs {
1122
1204
  const out: ParsedArgs = {};
1123
1205
  const args = argv.slice(2);
@@ -1140,10 +1222,21 @@ export function parseArgs(argv: string[]): ParsedArgs {
1140
1222
  const rest = args.slice(1);
1141
1223
 
1142
1224
  if (cmd === 'status') {
1143
- const json = rest.includes('--json');
1225
+ // Same unknown-option rejection loop as the other subcommands. The previous
1226
+ // ad-hoc rest.includes('--json') accepted anything — `status --jsn` and
1227
+ // `status --json=true` exited 0 with human output instead of failing fast.
1228
+ let json = false;
1144
1229
  let configPath: string | undefined;
1145
- const ci = rest.indexOf('--config');
1146
- if (ci !== -1 && rest[ci + 1]) configPath = rest[ci + 1];
1230
+ for (let i = 0; i < rest.length; i++) {
1231
+ const a = rest[i];
1232
+ if (a === '--json') json = true;
1233
+ else if (a === '--config') configPath = takeOptionValue(rest, ++i, '--config');
1234
+ else if (a?.startsWith('--')) {
1235
+ throw new UsageError(`unknown option for status: ${a}. Valid options: --json, --config <path>.`);
1236
+ } else if (a !== undefined) {
1237
+ throw new UsageError(`unexpected argument "${a}" after "status".`);
1238
+ }
1239
+ }
1147
1240
  out.command = 'status';
1148
1241
  out.status = { json };
1149
1242
  out.configPath = configPath;
@@ -1157,7 +1250,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
1157
1250
  for (let i = 0; i < rest.length; i++) {
1158
1251
  const a = rest[i];
1159
1252
  if (a === '--json') json = true;
1160
- else if (a === '--config' && rest[i + 1]) configPath = rest[++i];
1253
+ else if (a === '--config') configPath = takeOptionValue(rest, ++i, '--config');
1161
1254
  else if (a?.startsWith('--')) {
1162
1255
  throw new UsageError(`unknown option for knowledge: ${a}`);
1163
1256
  } else if (a !== undefined) {
@@ -1179,7 +1272,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
1179
1272
  for (let i = 0; i < rest.length; i++) {
1180
1273
  const a = rest[i];
1181
1274
  if (a === '--json') json = true;
1182
- else if (a === '--config' && rest[i + 1]) configPath = rest[++i];
1275
+ else if (a === '--config') configPath = takeOptionValue(rest, ++i, '--config');
1183
1276
  else if (a?.startsWith('--')) {
1184
1277
  throw new UsageError(`unknown option for knowledge-config: ${a}`);
1185
1278
  } else if (a !== undefined) {
@@ -1263,11 +1356,11 @@ export function parseArgs(argv: string[]): ParsedArgs {
1263
1356
  if (isNaN(n) || n < 0 || n > 3) throw new UsageError('--depth must be an integer 0–3.');
1264
1357
  depth = n;
1265
1358
  } else if (a === '--model') {
1266
- model = rest[++i];
1267
- if (!model) throw new UsageError('--model requires a value.');
1359
+ model = takeOptionValue(rest, ++i, '--model');
1268
1360
  } else if (a === '--exclude-tools') {
1269
- const v = rest[++i];
1270
- excludeTools = v ? v.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
1361
+ const v = takeOptionValue(rest, ++i, '--exclude-tools');
1362
+ excludeTools = v.split(',').map((s) => s.trim()).filter(Boolean);
1363
+ if (excludeTools.length === 0) excludeTools = undefined;
1271
1364
  } else if (a === '--initial-links') {
1272
1365
  // Consume until the next flag or end. Validate each entry: these are templated
1273
1366
  // verbatim into the researcher's "investigate these first" evidence block, so
@@ -1289,8 +1382,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
1289
1382
  }
1290
1383
  i = j - 1;
1291
1384
  } else if (a === '--config') {
1292
- configPath = rest[++i];
1293
- if (!configPath) throw new UsageError('--config requires a path.');
1385
+ configPath = takeOptionValue(rest, ++i, '--config');
1294
1386
  } else if (a === '--json') {
1295
1387
  json = true;
1296
1388
  } else if (a?.startsWith('--')) {
@@ -1380,14 +1472,63 @@ export function _markCancellationForTests(sig?: NodeJS.Signals): void {
1380
1472
  cancellationSignal = sig ?? null;
1381
1473
  }
1382
1474
 
1475
+ /**
1476
+ * When the FIRST cancelling signal was handled (epoch ms), or null before one
1477
+ * arrives. Module-scoped rather than a main() closure so the duplicate-vs-force
1478
+ * decision below is directly testable through the seams underneath.
1479
+ */
1480
+ let firstSignalAtMs: number | null = null;
1481
+
1482
+ /**
1483
+ * How long after the first signal a repeat still reads as a MECHANICAL
1484
+ * duplicate. The skill launcher forwards every cancelling signal to this
1485
+ * process — but with stdio:'inherit' a tty Ctrl-C has ALREADY delivered SIGINT
1486
+ * to the whole foreground group, so the forwarded copy lands within
1487
+ * milliseconds of the first. A human's second Ctrl-C at a stuck cleanup comes
1488
+ * seconds later; 1s cleanly separates the two.
1489
+ */
1490
+ const SIGNAL_FORCE_EXIT_GRACE_MS = 1_000;
1491
+
1492
+ export type SignalDisposition = 'begin-cleanup' | 'absorb-duplicate' | 'force-exit';
1493
+
1494
+ /**
1495
+ * Decide what a delivered cancelling signal means, latching the first arrival.
1496
+ * Exported as the test seam for the duplicate-absorption contract: the race
1497
+ * windows (a forwarded duplicate landing mid-safeShutdown) cannot be timed
1498
+ * reliably through a spawned CLI, so tests drive the decision directly —
1499
+ * mirroring the _markCancellationForTests seam above.
1500
+ */
1501
+ export function _signalDisposition(now: number = Date.now()): SignalDisposition {
1502
+ if (firstSignalAtMs === null) {
1503
+ firstSignalAtMs = now;
1504
+ return 'begin-cleanup';
1505
+ }
1506
+ return now - firstSignalAtMs > SIGNAL_FORCE_EXIT_GRACE_MS ? 'force-exit' : 'absorb-duplicate';
1507
+ }
1508
+
1509
+ export function _resetSignalDispositionForTests(): void {
1510
+ firstSignalAtMs = null;
1511
+ }
1512
+
1383
1513
  async function main(argv: string[]): Promise<number> {
1384
1514
  // Register signal handlers so SIGINT/SIGTERM always trigger SDK shutdown
1385
1515
  // before the process exits. safeShutdown() is idempotent — safe to call
1386
1516
  // before SDK init (it no-ops) and safe to call from multiple signals.
1387
- let _signalCleanupDone = false;
1388
1517
  const onSignal = (sig: string) => {
1389
- if (_signalCleanupDone) return;
1390
- _signalCleanupDone = true;
1518
+ switch (_signalDisposition()) {
1519
+ case 'absorb-duplicate':
1520
+ // The launcher's forwarded copy of a signal the tty already delivered
1521
+ // to the whole foreground group — cleanup is running; swallow it.
1522
+ return;
1523
+ case 'force-exit':
1524
+ // A repeat WELL after the first is a human (or an escalating harness)
1525
+ // at a stuck cleanup: honour it immediately, on the true 128+N.
1526
+ toStderr(`\n[pi-research] ${sig} again — force exit\n`);
1527
+ process.exit(exitCodeForSignal(sig as NodeJS.Signals));
1528
+ return;
1529
+ case 'begin-cleanup':
1530
+ break;
1531
+ }
1391
1532
  cancellationRequested = true;
1392
1533
  cancellationSignal = sig as NodeJS.Signals;
1393
1534
  toStderr(`\n[pi-research] ${sig} — cleaning up…\n`);
@@ -1404,8 +1545,15 @@ async function main(argv: string[]): Promise<number> {
1404
1545
  // exit status depend on whether this handler beat a force-kill.
1405
1546
  void safeShutdown().finally(() => flushAndExit(exitCodeForSignal(sig as NodeJS.Signals)));
1406
1547
  };
1407
- process.once('SIGINT', () => onSignal('SIGINT'));
1408
- process.once('SIGTERM', () => onSignal('SIGTERM'));
1548
+ // process.on, NOT process.once: a once-handler is CONSUMED by its first
1549
+ // delivery, restoring the default disposition — and the launcher forwards
1550
+ // every cancelling signal it receives, so a tty Ctrl-C (delivered group-wide
1551
+ // by the terminal AND re-forwarded by the launcher) had its duplicate kill
1552
+ // this process instantly, mid-safeShutdown, skipping browser/embedder
1553
+ // teardown. Persistent handlers absorb duplicates via _signalDisposition,
1554
+ // which still preserves a human force-quit escape after the grace window.
1555
+ process.on('SIGINT', () => onSignal('SIGINT'));
1556
+ process.on('SIGTERM', () => onSignal('SIGTERM'));
1409
1557
  // SIGHUP (terminal closed) and, on Windows, SIGBREAK (Ctrl+Break). Without these
1410
1558
  // the run dies without cancelling: no abort of the in-flight research, no browser
1411
1559
  // or embedding-server teardown, and no 128+N exit code. On Windows it matters
@@ -1414,9 +1562,9 @@ async function main(argv: string[]): Promise<number> {
1414
1562
  // The SDK already registers both (see _addSignalHandlers); the CLI did not, which
1415
1563
  // left the SDK's handler to tear the container down underneath a live orchestrator
1416
1564
  // — exactly the race the CLI installs its own handlers to avoid.
1417
- process.once('SIGHUP', () => onSignal('SIGHUP'));
1565
+ process.on('SIGHUP', () => onSignal('SIGHUP'));
1418
1566
  if (process.platform === 'win32') {
1419
- process.once('SIGBREAK', () => onSignal('SIGBREAK'));
1567
+ process.on('SIGBREAK', () => onSignal('SIGBREAK'));
1420
1568
  }
1421
1569
 
1422
1570
  let parsed: ParsedArgs;
@@ -1432,7 +1580,17 @@ async function main(argv: string[]): Promise<number> {
1432
1580
  // help/version are pure and need no credentials/config.
1433
1581
  const noBridge = new Set(['help', 'version']);
1434
1582
  if (!noBridge.has(parsed.command ?? '')) {
1435
- bridgeConfigEnv(parsed.configPath);
1583
+ try {
1584
+ bridgeConfigEnv(parsed.configPath);
1585
+ } catch (err) {
1586
+ if (err instanceof ConfigFileError) {
1587
+ // An explicit --config that does not apply is a setup error: silently
1588
+ // running on ambient config and reporting success would mask the typo.
1589
+ toStderr(`\nError: ${err.message}\n`);
1590
+ return EXIT.CONFIG;
1591
+ }
1592
+ throw err;
1593
+ }
1436
1594
  // Self-heal cross-harness skill installs on every engine-touching CLI run. The
1437
1595
  // CLI — not the interactive pi extension — is the surface that skill invocations
1438
1596
  // from Claude Code / Codex / OpenClaw actually drive, so without this an update