@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.
- package/README.md +3 -3
- package/dist/cli.mjs +2599 -1268
- package/dist/thread-worker.mjs +224 -147
- package/docs/AGENT-SKILL.md +11 -4
- package/docs/ARCHITECTURE.md +20 -10
- package/docs/CHANGELOG.md +195 -17
- package/docs/CONFIGURATION.md +9 -7
- package/docs/KNOWLEDGE-STORE.md +4 -3
- package/docs/PI-EXTENSION.md +1 -1
- package/docs/SDK.md +55 -0
- package/docs/THIRD-PARTY-NOTICES.md +1 -1
- package/package.json +5 -4
- package/scripts/cleanup.cjs +114 -12
- package/scripts/prepare.cjs +15 -5
- package/scripts/setup.cjs +24 -4
- package/skills/pi-research/README.md +9 -2
- package/skills/pi-research/SKILL.md +6 -3
- package/skills/pi-research/references/configuration.md +6 -2
- package/skills/pi-research/scripts/run.mjs +24 -2
- package/src/cleanup/research-cleanup.ts +12 -4
- package/src/cli.ts +372 -86
- package/src/config.ts +134 -17
- package/src/constants.ts +31 -0
- package/src/core/interfaces/health-check-interfaces.ts +3 -39
- package/src/core/interfaces/knowledge-interfaces.ts +6 -1
- package/src/core/interfaces/planning-interfaces.ts +8 -0
- package/src/core/interfaces/service-names.ts +0 -1
- package/src/core/interfaces/state-manager-interfaces.ts +11 -3
- package/src/core/llm/agentic-repair.ts +14 -0
- package/src/core/pi-version.ts +136 -0
- package/src/core/planning-service.ts +43 -1
- package/src/core/service-initialization.ts +6 -14
- package/src/core/service-interfaces.ts +1 -1
- package/src/core/service-registry.ts +117 -8
- package/src/healthcheck/index.ts +7 -2
- package/src/index.ts +11 -18
- package/src/infrastructure/browser/browser-client.ts +80 -13
- package/src/infrastructure/browser/browser-error-utils.ts +6 -1
- package/src/infrastructure/browser/browser-server.ts +41 -12
- package/src/infrastructure/browser/browser-task-scheduler.ts +159 -35
- package/src/infrastructure/browser/cleanup-utils.ts +95 -9
- package/src/infrastructure/browser/client-agent.ts +6 -5
- package/src/infrastructure/browser/config.ts +14 -3
- package/src/infrastructure/browser/priority-task-queue.ts +10 -3
- package/src/infrastructure/browser/scheduler-factory.ts +44 -4
- package/src/infrastructure/browser/task-execution-service.ts +92 -28
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
- package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
- package/src/infrastructure/browser/thread-worker-types.ts +3 -1
- package/src/infrastructure/browser/thread-worker.mjs +224 -147
- package/src/infrastructure/browser/worker-pool-manager.ts +18 -5
- package/src/infrastructure/embedding/embedding-client.ts +22 -8
- package/src/infrastructure/embedding/embedding-factory.ts +61 -15
- package/src/infrastructure/embedding/embedding-server.ts +105 -11
- package/src/infrastructure/file-lock-service.ts +295 -28
- package/src/infrastructure/knowledge-store-service.ts +48 -3
- package/src/infrastructure/process-lifecycle-service.ts +134 -34
- package/src/infrastructure/research-run-semaphore.ts +37 -3
- package/src/infrastructure/state/state-backup-manager.ts +5 -7
- package/src/infrastructure/state/state-browser-api.ts +5 -2
- package/src/infrastructure/state/state-browser-manager.ts +15 -1
- package/src/infrastructure/state/state-manager-service.ts +5 -5
- package/src/infrastructure/state/state-manager.ts +34 -23
- package/src/infrastructure/types/state-types.ts +9 -0
- package/src/knowledge/chunker.ts +88 -1
- package/src/knowledge/embedder-init.ts +1 -1
- package/src/knowledge/embedder.ts +188 -46
- package/src/knowledge/index.ts +1 -1
- package/src/knowledge/store.ts +101 -21
- package/src/knowledge/webgpu-viability.ts +9 -4
- package/src/knowledge/writer-queue.ts +88 -46
- package/src/logger.ts +5 -1
- package/src/observers/research-observer-impl.ts +31 -0
- package/src/orchestration/abort-utils.ts +60 -0
- package/src/orchestration/deep-research-orchestrator.ts +25 -4
- package/src/orchestration/headless-observer.ts +8 -0
- package/src/orchestration/quick-research-orchestrator.ts +2 -2
- package/src/orchestration/research-orchestration-service.ts +65 -15
- package/src/orchestration/research-session-service.ts +37 -11
- package/src/orchestration/research-synthesis-service.ts +33 -11
- package/src/orchestration/researcher-executor.ts +100 -20
- package/src/orchestration/researcher.ts +39 -20
- package/src/orchestration/session-state.ts +11 -0
- package/src/sdk.ts +125 -19
- package/src/security/cisa-kev.ts +10 -3
- package/src/security/github-advisories.ts +18 -7
- package/src/security/index.ts +51 -30
- package/src/security/nvd-client.ts +24 -8
- package/src/security/osv-client.ts +14 -4
- package/src/skill-install/skill-installer.ts +153 -11
- package/src/stackexchange/index.ts +20 -5
- package/src/stackexchange/output/table.ts +7 -2
- package/src/stackexchange/rest-client.ts +41 -3
- package/src/tools/research-knowledge-search.ts +15 -0
- package/src/tools/research-tool-definition.ts +8 -2
- package/src/tools/scrape.ts +48 -25
- package/src/tools/search.ts +16 -2
- package/src/tools/security.ts +68 -7
- package/src/tui/research-health.ts +18 -2
- package/src/utils/atomic-replace.ts +101 -0
- package/src/utils/circuit-breaker.ts +9 -2
- package/src/utils/citation-utils.ts +3 -1
- package/src/utils/disk-space-checker.ts +11 -6
- package/src/utils/http-body.ts +176 -0
- package/src/utils/json-utils.ts +116 -76
- package/src/utils/llm-usage.ts +17 -1
- package/src/utils/log-rotation.ts +42 -10
- package/src/utils/log-utils.ts +40 -0
- package/src/utils/metrics-summary.ts +27 -9
- package/src/utils/stdio-capture.ts +63 -22
- package/src/utils/text-utils.ts +8 -1
- package/src/utils/tool-usage-tracker.ts +9 -2
- package/src/web-research/browser-search.ts +70 -5
- package/src/web-research/scraper-utils.ts +13 -0
- package/src/web-research/search.ts +25 -6
- package/src/web-research/types.ts +19 -4
- package/src/web-research/web-scraper.ts +72 -54
- package/src/youtube/transcript-client.ts +5 -1
- 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)
|
|
176
|
-
*
|
|
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
|
-
|
|
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))
|
|
196
|
-
|
|
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
|
-
|
|
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
|
|
204
|
-
// config.env out-rank the per-directory registry, defeating a
|
|
205
|
-
// still applied as a lower-precedence file layer by
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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: '
|
|
813
|
+
toStdout(pretty({ command: 'knowledge-config', action: 'show', ...info, cwd }));
|
|
682
814
|
return EXIT.OK;
|
|
683
815
|
}
|
|
684
816
|
toStdout(
|
|
685
|
-
`knowledge store mode
|
|
686
|
-
` scope: ${
|
|
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
|
-
|
|
690
|
-
|
|
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 =
|
|
884
|
-
? EXIT.
|
|
885
|
-
:
|
|
886
|
-
? EXIT.
|
|
887
|
-
:
|
|
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
|
-
|
|
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>
|
|
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>
|
|
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>
|
|
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>
|
|
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
|
|
1028
|
-
64
|
|
1029
|
-
75
|
|
1030
|
-
78
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1061
|
-
|
|
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'
|
|
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'
|
|
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
|
|
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
|
|
1185
|
-
excludeTools = v
|
|
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
|
|
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
|
-
|
|
1279
|
-
|
|
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
|
-
|
|
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.
|
|
1292
|
-
|
|
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
|
-
|
|
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
|