@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
package/src/cli.ts CHANGED
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import * as fs from 'node:fs';
25
+ import * as os from 'node:os';
25
26
  import * as path from 'node:path';
26
27
  import { fileURLToPath } from 'node:url';
27
28
 
@@ -84,8 +85,47 @@ export const EXIT = {
84
85
  * re-running a broken command.
85
86
  */
86
87
  TEMPFAIL: 75,
88
+ /**
89
+ * The run was CANCELLED via a programmatic abort (the caller's AbortSignal),
90
+ * with no OS signal involved. 130 — 128+SIGINT — is the conventional
91
+ * "interrupted" code and the natural default when there is no signal number to
92
+ * derive one from.
93
+ *
94
+ * A cancellation caused by an actual SIGNAL does NOT use this constant: see
95
+ * exitCodeForSignal below, which reports the true 128+N.
96
+ *
97
+ * Distinct from SOFTWARE for the same reason TEMPFAIL is: a deliberate stop is
98
+ * not a fault. Reported as SOFTWARE (70), a cancel matched the skill contract's
99
+ * "runtime error — suggest one retry for transient failures", so an agent
100
+ * relaying the result would offer to re-run the very research the user had just
101
+ * interrupted, and a --json consumer could not tell a cancel from a crash.
102
+ */
103
+ CANCELLED: 130,
87
104
  } as const;
88
105
 
106
+ /**
107
+ * Exit code for a run stopped by `sig`, following the POSIX 128+N convention that
108
+ * a shell uses when reporting a signal-terminated child (SIGINT → 130,
109
+ * SIGTERM → 143, SIGHUP → 129, SIGQUIT → 131).
110
+ *
111
+ * Why derive it per-signal instead of returning a single "cancelled" code: this
112
+ * process INSTALLS handlers for those signals, so it is never actually killed by
113
+ * them — it catches them and exits deliberately. If it exited a flat 130, the code
114
+ * a wrapper observes would depend on whether our handler won the race against a
115
+ * force-kill: handler wins → 130, handler loses → the shell's 143. Deriving 128+N
116
+ * makes the handler transparent, so the same user action reports the same number
117
+ * either way.
118
+ *
119
+ * Every value lands in 129..143, safely inside the 0-255 range a POSIX wait status
120
+ * preserves — nothing here can be truncated to a false 0. Windows does not
121
+ * implement the 128+N convention at all (its exit codes are 32-bit and carry no
122
+ * signal semantics), so there these are simply our documented contract values.
123
+ */
124
+ export function exitCodeForSignal(sig: NodeJS.Signals): number {
125
+ const signum = (os.constants.signals as Record<string, number | undefined>)[sig];
126
+ return typeof signum === 'number' ? 128 + signum : EXIT.CANCELLED;
127
+ }
128
+
89
129
  // ---------------------------------------------------------------------------
90
130
  // Output helpers
91
131
  // ---------------------------------------------------------------------------
@@ -169,11 +209,24 @@ function makeProgressObserver(): HeadlessObserverOptions {
169
209
  // ---------------------------------------------------------------------------
170
210
 
171
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
+
172
222
  /**
173
223
  * Bridge the CLI's config files into process.env for keys not already set in the
174
224
  * real environment. Layers the base `~/.pi/research/config.env` then the CLI's
175
- * own `~/.pi/research/cli.env` overlay (overlay wins), or a `--config <path>`
176
- * 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.
177
230
  *
178
231
  * Why: auth values (PI_RESEARCH_API_KEY / _PROVIDER) are read directly from
179
232
  * process.env, NOT from the typed Config, so a key placed only in a config file
@@ -190,20 +243,52 @@ export function bridgeConfigEnv(explicitConfigPath?: string): { path: string; lo
190
243
  // iteration and the "don't clobber the real environment" guard would then
191
244
  // block the overlay's value — cli.env could never override config.env.
192
245
  const merged: Record<string, string> = {};
193
- for (const filePath of [basePath, cliOverlayPath]) {
246
+ // Keys whose winning value came from an EXPLICIT --config file (not the
247
+ // default base, not the cli.env overlay). Project-scoped keys are only
248
+ // bridged for these — see below.
249
+ const fromExplicitBase = new Set<string>();
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;
194
262
  try {
195
- if (!fs.existsSync(filePath)) continue;
196
- Object.assign(merged, parseDotEnv(fs.readFileSync(filePath, 'utf-8')));
263
+ if (!fs.existsSync(filePath)) {
264
+ if (isExplicit) throw new ConfigFileError(`--config file not found: ${filePath}`);
265
+ continue;
266
+ }
267
+ const parsed = parseDotEnv(fs.readFileSync(filePath, 'utf-8'));
268
+ Object.assign(merged, parsed);
269
+ if (isExplicit) {
270
+ for (const k of Object.keys(parsed)) fromExplicitBase.add(k);
271
+ }
197
272
  loaded = true;
198
- } catch {
199
- // 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
200
281
  }
201
282
  }
202
283
  for (const [k, v] of Object.entries(merged)) {
203
- // Skip per-directory (project-scoped) keys: promoting them into process.env would make
204
- // config.env out-rank the per-directory registry, defeating a per-cwd override. They are
205
- // still applied as a lower-precedence file layer by getConfig()/loadEnvFiles().
206
- if (isProjectScopedKey(k)) continue;
284
+ // Skip per-directory (project-scoped) keys from the AMBIENT files: promoting them into
285
+ // process.env would make config.env out-rank the per-directory registry, defeating a
286
+ // per-cwd override. They are still applied as a lower-precedence file layer by
287
+ // getConfig()/loadEnvFiles(). An EXPLICIT --config file is different: getConfig never
288
+ // reads that path, so skipping its keys silently dropped them (a depth or
289
+ // knowledge-mode set in `--config ci.env` simply did not apply) — and a file the user
290
+ // named on this exact invocation outranking the per-directory registry is the intent.
291
+ if (isProjectScopedKey(k) && !fromExplicitBase.has(k)) continue;
207
292
  if (process.env[k] === undefined && (k.startsWith('PI_RESEARCH_') || k === 'STACKEXCHANGE_API_KEY' || k === 'GITHUB_TOKEN' || k === 'NVD_API_KEY')) {
208
293
  process.env[k] = v;
209
294
  }
@@ -461,7 +546,7 @@ interface ResearchArgs {
461
546
  * Research subcommand. Initializes the SDK, runs deep/quick research, prints the
462
547
  * markdown report to stdout, and always shuts down (even on failure).
463
548
  */
464
- async function cmdResearch(args: ResearchArgs): Promise<number> {
549
+ export async function cmdResearch(args: ResearchArgs): Promise<number> {
465
550
  // Pre-flight with the per-run --model flag folded in: it satisfies the
466
551
  // model-required check and steers the provider-aware key detection.
467
552
  const det = await detectCredentials(args.model);
@@ -534,6 +619,18 @@ async function cmdResearch(args: ResearchArgs): Promise<number> {
534
619
  }
535
620
  }
536
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
+
537
634
  if (args.json) {
538
635
  // Surface the internal quality/health signals that were previously log-only
539
636
  // (ISSUES-ANALYSIS-2026-07-09.md, Symptom 4): a caller reading only stdout/JSON
@@ -546,12 +643,20 @@ async function cmdResearch(args: ResearchArgs): Promise<number> {
546
643
  depth,
547
644
  report,
548
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 } : {}),
549
650
  ...(outcome ? { researchers: { planned: outcome.planned, launched: outcome.launched, succeeded: outcome.succeeded, failed: outcome.failed } } : {}),
550
651
  ...(errorReport && errorReport.totalErrors > 0 ? {
551
652
  trackedErrors: { total: errorReport.totalErrors, uniquePatterns: errorReport.uniquePatterns },
552
653
  } : {}),
553
654
  }));
554
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');
555
660
  toStdout(report.endsWith('\n') ? report : report + '\n');
556
661
  }
557
662
  } catch (err) {
@@ -568,7 +673,7 @@ async function cmdResearch(args: ResearchArgs): Promise<number> {
568
673
  * result; `found: 'no'`/`'maybe'` tells the caller to follow up with live
569
674
  * research.
570
675
  */
571
- async function cmdKnowledge(queries: string[], json?: boolean): Promise<number> {
676
+ export async function cmdKnowledge(queries: string[], json?: boolean): Promise<number> {
572
677
  const det = await detectCredentials();
573
678
  if (det.problem) {
574
679
  if (json) { toStdout(pretty({ ok: false, error: det.problem, exitCode: EXIT.CONFIG })); return EXIT.CONFIG; }
@@ -583,7 +688,7 @@ async function cmdKnowledge(queries: string[], json?: boolean): Promise<number>
583
688
  'Knowledge store is disabled (PI_RESEARCH_KNOWLEDGE_STORE_MODE=none). ' +
584
689
  "Set it to 'project' or 'global' to enable local knowledge search.\n\n" +
585
690
  `Configure in:\n ${paths.configEnv}\n ${paths.cliIfaceEnv} (cli overlay)\n or via PI_RESEARCH_KNOWLEDGE_STORE_MODE env var`;
586
- if (json) toStdout(pretty({ found: 'no', text: msg, configured: false }));
691
+ if (json) toStdout(pretty({ ok: true, found: 'no', text: msg, configured: false }));
587
692
  else toStderr(`\n[pi-research] ${msg}\n`);
588
693
  return EXIT.CONFIG;
589
694
  }
@@ -607,7 +712,10 @@ async function cmdKnowledge(queries: string[], json?: boolean): Promise<number>
607
712
  }
608
713
 
609
714
  if (json) {
610
- 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 }));
611
719
  } else {
612
720
  toStdout((result.text.endsWith('\n') ? result.text : result.text + '\n'));
613
721
  }
@@ -662,51 +770,60 @@ async function cmdStatus(json?: boolean): Promise<number> {
662
770
  * setting on the user's behalf without hand-editing the locked registry JSON. A machine-wide
663
771
  * default is set instead via the PI_RESEARCH_KNOWLEDGE_STORE_MODE env var (per-run) or config.env.
664
772
  */
665
- async function cmdKnowledgeConfig(kc: NonNullable<ParsedArgs['knowledgeConfig']>): Promise<number> {
773
+ export async function cmdKnowledgeConfig(kc: NonNullable<ParsedArgs['knowledgeConfig']>): Promise<number> {
666
774
  const cwd = process.cwd();
667
775
 
668
- if (kc.action === 'set') {
669
- const before = getConfig(cwd, 'cli').KNOWLEDGE_STORE_MODE;
670
- const cfg = { ...getConfig(cwd, 'cli'), KNOWLEDGE_STORE_MODE: kc.mode! };
671
- // Persist ONLY the knowledge-store mode for this directory — not the whole local-key set —
672
- // so an unrelated per-directory DEFAULT_RESEARCH_DEPTH isn't frozen as a side effect.
673
- saveConfig(cfg, 'local', cwd, ['PI_RESEARCH_KNOWLEDGE_STORE_MODE']); // per-directory registry write
674
- 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
675
811
  const info = describeKnowledgeStoreMode(cwd, 'cli');
676
- // The write always persists to the registry, but a real env var out-ranks the registry, so
677
- // the EFFECTIVE mode can still differ from what was requested. Surface that instead of
678
- // silently reporting a no-op.
679
- const overridden = info.mode !== kc.mode;
680
812
  if (kc.json) {
681
- 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 }));
682
814
  return EXIT.OK;
683
815
  }
684
816
  toStdout(
685
- `knowledge store mode for this directory: ${before} -> ${kc.mode} (saved per-directory)\n` +
686
- ` scope: ${kc.mode === 'none' ? 'disabled here' : kc.mode === 'project' ? 'this directory only' : 'shared across all directories'}\n` +
687
- ` 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` +
688
819
  ` db dir: ${info.dbDir}\n` +
689
- (overridden
690
- ? `\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`
691
- : ''),
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`,
692
822
  );
693
823
  return EXIT.OK;
824
+ } catch (err) {
825
+ return reportError(err, 'knowledge-config', kc.json);
694
826
  }
695
-
696
- // show
697
- const info = describeKnowledgeStoreMode(cwd, 'cli');
698
- if (kc.json) {
699
- toStdout(pretty({ command: 'knowledge-config', action: 'show', ...info, cwd }));
700
- return EXIT.OK;
701
- }
702
- toStdout(
703
- `knowledge store mode: ${info.mode} (source: ${info.origin})\n` +
704
- ` scope: ${info.mode === 'none' ? 'disabled here' : info.mode === 'project' ? 'this directory only' : 'shared across all directories'}\n` +
705
- ` db dir: ${info.dbDir}\n` +
706
- `\nchange it for THIS directory: ${BINARY_NAME} knowledge-config set <none|project|global>\n` +
707
- `change the machine-wide default: set PI_RESEARCH_KNOWLEDGE_STORE_MODE=<mode> in ${resolvedConfigPaths().configEnv}\n`,
708
- );
709
- return EXIT.OK;
710
827
  }
711
828
 
712
829
  // ---------------------------------------------------------------------------
@@ -863,9 +980,25 @@ export async function reportError(err: unknown, what: string, json?: boolean): P
863
980
  // machine as a setup problem would send the user to reconfigure a working install.
864
981
  const isCapacity = err instanceof Error && err.name === 'ResearchRunCapacityError';
865
982
 
983
+ // Cancellation. Primary signal is the latch set by the SIGINT/SIGTERM handler —
984
+ // an exact fact, unlike the message, which varies by whichever subsystem noticed
985
+ // the abort first ('Aborted' from the search layer, 'Research aborted' /
986
+ // 'Research cancelled' from the orchestrator). The anchored message signatures
987
+ // only cover a programmatic abort where no signal arrived. Deliberately NOT
988
+ // matched: a bare `err.name === 'AbortError'` — internal timeout aborts
989
+ // (AbortSignal-driven retry/scrape deadlines) throw that name too, and an
990
+ // escaped one would report "cancelled — do not re-run" for a run nobody
991
+ // stopped. In the CLI every real cancellation sets the latch or carries these
992
+ // exact signatures. Checked before everything below: an abort can unwind
993
+ // through a provider call and surface wording that the config/rate-limit
994
+ // heuristics would otherwise claim.
995
+ const isCancellation =
996
+ cancellationRequested ||
997
+ (err instanceof Error && /^(aborted|research (aborted|cancelled))$/i.test(msg));
998
+
866
999
  // Auth / model / config-shaped errors → CONFIG exit + config block.
867
1000
  const isConfigError =
868
- !isTaggedResearchStop && !isCapacity && (
1001
+ !isTaggedResearchStop && !isCapacity && !isCancellation && (
869
1002
  lower.includes('no llm model available') ||
870
1003
  lower.includes('not found in pi') ||
871
1004
  lower.includes('no api key') ||
@@ -879,20 +1012,27 @@ export async function reportError(err: unknown, what: string, json?: boolean): P
879
1012
  // "api key" in its body (e.g. "rate limit exceeded for this api key"), and
880
1013
  // classifying that as a setup problem sends the user to reconfigure a
881
1014
  // perfectly working key instead of waiting out the limit.
882
- const isRateLimit = lower.includes('429') || lower.includes('rate limit') || lower.includes('too many requests');
883
- const exitCode = isCapacity
884
- ? EXIT.TEMPFAIL
885
- : isConfigError && !isRateLimit
886
- ? EXIT.CONFIG
887
- : EXIT.SOFTWARE;
1015
+ const isRateLimit = !isCancellation && (lower.includes('429') || lower.includes('rate limit') || lower.includes('too many requests'));
1016
+ const exitCode = isCancellation
1017
+ ? (cancellationSignal ? exitCodeForSignal(cancellationSignal) : EXIT.CANCELLED)
1018
+ : isCapacity
1019
+ ? EXIT.TEMPFAIL
1020
+ : isConfigError && !isRateLimit
1021
+ ? EXIT.CONFIG
1022
+ : EXIT.SOFTWARE;
888
1023
 
889
1024
  // --json: emit a structured error on stdout so a machine consumer never has to
890
1025
  // special-case plain-text stderr. Mirrors the { ok: true, ... } success shape.
891
1026
  if (json) {
892
1027
  const payload: Record<string, unknown> = { ok: false, error: msg, exitCode };
893
1028
  // Machine-readable "this will succeed later" flag, so a consumer can back off
894
- // and retry without string-matching the human message.
895
- if (isCapacity || isRateLimit) payload['retryable'] = true;
1029
+ // and retry without string-matching the human message. Gated on cancellation:
1030
+ // a Ctrl-C landing in the same window a queue-wait expires must never emit
1031
+ // `cancelled: true` alongside `retryable: true` — a consumer honouring the
1032
+ // latter would re-run the research the user just stopped.
1033
+ if ((isCapacity || isRateLimit) && !isCancellation) payload['retryable'] = true;
1034
+ // A cancel is emphatically NOT retryable-by-default: the user asked to stop.
1035
+ if (isCancellation) payload['cancelled'] = true;
896
1036
  if (process.env['PI_RESEARCH_DEBUG'] === 'true' && err instanceof Error && err.stack) {
897
1037
  payload['stack'] = err.stack;
898
1038
  }
@@ -900,6 +1040,13 @@ export async function reportError(err: unknown, what: string, json?: boolean): P
900
1040
  return exitCode;
901
1041
  }
902
1042
 
1043
+ // Cancellation is a completed intention, not a failure — never a stack trace,
1044
+ // and never the word "failed".
1045
+ if (isCancellation) {
1046
+ toStderr(`\npi-research ${what} cancelled.\n`);
1047
+ return exitCode;
1048
+ }
1049
+
903
1050
  // Capacity exhaustion is a healthy-machine "busy" state — report it as such and
904
1051
  // never with a stack trace, which would read as a crash for a routine condition.
905
1052
  if (isCapacity) {
@@ -975,21 +1122,25 @@ COMMANDS
975
1122
  --model <provider/id> Model for this run (outranks PI_RESEARCH_MODEL; satisfies the required-model check).
976
1123
  --exclude-tools <a,b> Disable internal tools (e.g. security_search,stackexchange).
977
1124
  --initial-links <url ...> Seed URLs to investigate first; requires a query (-- ends options).
978
- --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).
979
1127
  --json Emit a JSON object instead of a markdown report.
980
1128
 
981
1129
  knowledge "<q1>" ["<q2>" ...] Search local knowledge store before live research (up to 5 queries).
982
- --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).
983
1132
  --json Emit a JSON object.
984
1133
 
985
1134
  knowledge-config [show] Show the knowledge-store mode for this directory + its source.
986
- --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).
987
1137
  --json Emit a JSON object.
988
1138
  knowledge-config set <mode> Set the mode for THIS directory (none | project | global);
989
1139
  persisted per-directory. Takes effect on the next run.
990
1140
 
991
1141
  status Show detected config, model/key, and readiness.
992
- --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).
993
1144
  --json Emit a JSON object.
994
1145
 
995
1146
  skill [status] Show where the agent skill is installed across your coding agents.
@@ -1024,15 +1175,31 @@ CONFIGURE
1024
1175
  Run \`${BINARY_NAME} status\` to see exactly what is detected.
1025
1176
 
1026
1177
  EXIT CODES
1027
- 0 success
1028
- 64 bad arguments
1029
- 75 at capacity — every concurrent run slot is busy; nothing is broken, retry later
1030
- 78 not configured — message prints the exact locations to fix
1031
- (on \`knowledge\`, also returned when the store is deliberately disabled)
1032
- 70 runtime error (network, provider, internal)
1178
+ 0 success
1179
+ 64 bad arguments
1180
+ 75 at capacity — every concurrent run slot is busy; nothing is broken, retry later
1181
+ 78 not configured — message prints the exact locations to fix
1182
+ (on \`knowledge\`, also returned when the store is deliberately disabled)
1183
+ 130 cancelled — Ctrl-C; nothing failed, do not auto-retry
1184
+ (other stop signals report POSIX 128+N: 143 SIGTERM, 129 SIGHUP, 131 SIGQUIT)
1185
+ 70 runtime error (network, provider, internal)
1033
1186
  `;
1034
1187
  }
1035
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
+
1036
1203
  export function parseArgs(argv: string[]): ParsedArgs {
1037
1204
  const out: ParsedArgs = {};
1038
1205
  const args = argv.slice(2);
@@ -1055,10 +1222,21 @@ export function parseArgs(argv: string[]): ParsedArgs {
1055
1222
  const rest = args.slice(1);
1056
1223
 
1057
1224
  if (cmd === 'status') {
1058
- 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;
1059
1229
  let configPath: string | undefined;
1060
- const ci = rest.indexOf('--config');
1061
- 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
+ }
1062
1240
  out.command = 'status';
1063
1241
  out.status = { json };
1064
1242
  out.configPath = configPath;
@@ -1072,7 +1250,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
1072
1250
  for (let i = 0; i < rest.length; i++) {
1073
1251
  const a = rest[i];
1074
1252
  if (a === '--json') json = true;
1075
- else if (a === '--config' && rest[i + 1]) configPath = rest[++i];
1253
+ else if (a === '--config') configPath = takeOptionValue(rest, ++i, '--config');
1076
1254
  else if (a?.startsWith('--')) {
1077
1255
  throw new UsageError(`unknown option for knowledge: ${a}`);
1078
1256
  } else if (a !== undefined) {
@@ -1094,7 +1272,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
1094
1272
  for (let i = 0; i < rest.length; i++) {
1095
1273
  const a = rest[i];
1096
1274
  if (a === '--json') json = true;
1097
- else if (a === '--config' && rest[i + 1]) configPath = rest[++i];
1275
+ else if (a === '--config') configPath = takeOptionValue(rest, ++i, '--config');
1098
1276
  else if (a?.startsWith('--')) {
1099
1277
  throw new UsageError(`unknown option for knowledge-config: ${a}`);
1100
1278
  } else if (a !== undefined) {
@@ -1178,11 +1356,11 @@ export function parseArgs(argv: string[]): ParsedArgs {
1178
1356
  if (isNaN(n) || n < 0 || n > 3) throw new UsageError('--depth must be an integer 0–3.');
1179
1357
  depth = n;
1180
1358
  } else if (a === '--model') {
1181
- model = rest[++i];
1182
- if (!model) throw new UsageError('--model requires a value.');
1359
+ model = takeOptionValue(rest, ++i, '--model');
1183
1360
  } else if (a === '--exclude-tools') {
1184
- const v = rest[++i];
1185
- 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;
1186
1364
  } else if (a === '--initial-links') {
1187
1365
  // Consume until the next flag or end. Validate each entry: these are templated
1188
1366
  // verbatim into the researcher's "investigate these first" evidence block, so
@@ -1204,8 +1382,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
1204
1382
  }
1205
1383
  i = j - 1;
1206
1384
  } else if (a === '--config') {
1207
- configPath = rest[++i];
1208
- if (!configPath) throw new UsageError('--config requires a path.');
1385
+ configPath = takeOptionValue(rest, ++i, '--config');
1209
1386
  } else if (a === '--json') {
1210
1387
  json = true;
1211
1388
  } else if (a?.startsWith('--')) {
@@ -1269,14 +1446,91 @@ const flushAndExit = (code: number): void => {
1269
1446
  // outlives that one run.
1270
1447
  let activeResearchAbortController: AbortController | null = null;
1271
1448
 
1449
+ /**
1450
+ * Set once SIGINT/SIGTERM has been handled. Read by reportError so a cancellation
1451
+ * is classified by the fact that we were signalled, not by pattern-matching the
1452
+ * message an aborted subsystem happened to throw.
1453
+ */
1454
+ let cancellationRequested = false;
1455
+
1456
+ /**
1457
+ * WHICH signal fired — so a cancellation that unwinds through reportError
1458
+ * reports the same 128+N the signal path itself would (SIGTERM → 143, not a
1459
+ * flat 130). reportError races onSignal's own flushAndExit; latching the name
1460
+ * makes both racers agree on the number, and keeps the `exitCode` field in
1461
+ * `--json` from contradicting the actual exit status.
1462
+ */
1463
+ let cancellationSignal: NodeJS.Signals | null = null;
1464
+
1465
+ /** Test seams for the module-level cancellation latch (set only by onSignal). */
1466
+ export function _resetCancellationLatchForTests(): void {
1467
+ cancellationRequested = false;
1468
+ cancellationSignal = null;
1469
+ }
1470
+ export function _markCancellationForTests(sig?: NodeJS.Signals): void {
1471
+ cancellationRequested = true;
1472
+ cancellationSignal = sig ?? null;
1473
+ }
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
+
1272
1513
  async function main(argv: string[]): Promise<number> {
1273
1514
  // Register signal handlers so SIGINT/SIGTERM always trigger SDK shutdown
1274
1515
  // before the process exits. safeShutdown() is idempotent — safe to call
1275
1516
  // before SDK init (it no-ops) and safe to call from multiple signals.
1276
- let _signalCleanupDone = false;
1277
1517
  const onSignal = (sig: string) => {
1278
- if (_signalCleanupDone) return;
1279
- _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
+ }
1532
+ cancellationRequested = true;
1533
+ cancellationSignal = sig as NodeJS.Signals;
1280
1534
  toStderr(`\n[pi-research] ${sig} — cleaning up…\n`);
1281
1535
  // Abort the in-flight research run FIRST (synchronously ahead of the shutdown
1282
1536
  // teardown below) so the orchestrator's existing signal-aware cancellation path
@@ -1286,10 +1540,32 @@ async function main(argv: string[]): Promise<number> {
1286
1540
  // Fire-and-forget: we can't await here, but safeShutdown swallows errors
1287
1541
  // and the finally blocks in each command handler also call it. Drain via
1288
1542
  // flushAndExit (not a bare process.exit) so buffered stdout isn't truncated.
1289
- void safeShutdown().finally(() => flushAndExit(EXIT.SOFTWARE));
1543
+ // 128+N, not a flat CANCELLED: see exitCodeForSignal. Because we handle these
1544
+ // signals rather than dying from them, a fixed code would make the observed
1545
+ // exit status depend on whether this handler beat a force-kill.
1546
+ void safeShutdown().finally(() => flushAndExit(exitCodeForSignal(sig as NodeJS.Signals)));
1290
1547
  };
1291
- process.once('SIGINT', () => onSignal('SIGINT'));
1292
- 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'));
1557
+ // SIGHUP (terminal closed) and, on Windows, SIGBREAK (Ctrl+Break). Without these
1558
+ // the run dies without cancelling: no abort of the in-flight research, no browser
1559
+ // or embedding-server teardown, and no 128+N exit code. On Windows it matters
1560
+ // most — SIGTERM is never delivered there (process.kill maps to TerminateProcess),
1561
+ // so Ctrl-C and Ctrl+Break are the only signals that reach this handler at all.
1562
+ // The SDK already registers both (see _addSignalHandlers); the CLI did not, which
1563
+ // left the SDK's handler to tear the container down underneath a live orchestrator
1564
+ // — exactly the race the CLI installs its own handlers to avoid.
1565
+ process.on('SIGHUP', () => onSignal('SIGHUP'));
1566
+ if (process.platform === 'win32') {
1567
+ process.on('SIGBREAK', () => onSignal('SIGBREAK'));
1568
+ }
1293
1569
 
1294
1570
  let parsed: ParsedArgs;
1295
1571
  try {
@@ -1304,7 +1580,17 @@ async function main(argv: string[]): Promise<number> {
1304
1580
  // help/version are pure and need no credentials/config.
1305
1581
  const noBridge = new Set(['help', 'version']);
1306
1582
  if (!noBridge.has(parsed.command ?? '')) {
1307
- 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
+ }
1308
1594
  // Self-heal cross-harness skill installs on every engine-touching CLI run. The
1309
1595
  // CLI — not the interactive pi extension — is the surface that skill invocations
1310
1596
  // from Claude Code / Codex / OpenClaw actually drive, so without this an update