@lincoln504/pi-research 1.2.0 → 1.3.1
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/dist/cli.mjs +1334 -750
- package/dist/thread-worker.mjs +194 -136
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CHANGELOG.md +129 -16
- package/package.json +1 -1
- package/scripts/cleanup.cjs +64 -2
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cleanup/research-cleanup.ts +12 -4
- package/src/cli.ts +228 -70
- package/src/config.ts +78 -2
- package/src/core/interfaces/health-check-interfaces.ts +3 -39
- 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 +10 -2
- package/src/core/llm/agentic-repair.ts +14 -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/healthcheck/index.ts +7 -2
- package/src/infrastructure/browser/browser-client.ts +64 -9
- package/src/infrastructure/browser/browser-server.ts +24 -7
- package/src/infrastructure/browser/browser-task-scheduler.ts +58 -12
- package/src/infrastructure/browser/cleanup-utils.ts +50 -2
- package/src/infrastructure/browser/client-agent.ts +6 -6
- package/src/infrastructure/browser/config.ts +14 -3
- package/src/infrastructure/browser/scheduler-factory.ts +18 -3
- 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 +194 -136
- package/src/infrastructure/browser/worker-pool-manager.ts +7 -2
- package/src/infrastructure/embedding/embedding-factory.ts +35 -6
- package/src/infrastructure/embedding/embedding-server.ts +40 -7
- package/src/infrastructure/file-lock-service.ts +72 -2
- package/src/infrastructure/knowledge-store-service.ts +48 -3
- package/src/infrastructure/process-lifecycle-service.ts +28 -5
- package/src/infrastructure/research-run-semaphore.ts +37 -3
- 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 +4 -4
- package/src/infrastructure/state/state-manager.ts +15 -3
- package/src/knowledge/chunker.ts +88 -1
- package/src/knowledge/embedder.ts +136 -43
- package/src/knowledge/store.ts +66 -12
- 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 +12 -2
- package/src/orchestration/headless-observer.ts +8 -0
- package/src/orchestration/research-orchestration-service.ts +63 -11
- package/src/orchestration/research-session-service.ts +37 -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/index.ts +51 -30
- package/src/security/nvd-client.ts +7 -2
- package/src/skill-install/skill-installer.ts +18 -2
- package/src/stackexchange/index.ts +20 -5
- package/src/stackexchange/output/table.ts +7 -2
- package/src/stackexchange/rest-client.ts +35 -1
- package/src/tools/research-tool-definition.ts +42 -8
- package/src/tools/scrape.ts +48 -25
- package/src/tools/search.ts +10 -1
- package/src/tools/security.ts +68 -7
- package/src/tui/research-health.ts +18 -2
- package/src/utils/circuit-breaker.ts +9 -2
- package/src/utils/disk-space-checker.ts +11 -6
- 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 +26 -7
- package/src/web-research/scraper-utils.ts +13 -0
- package/src/web-research/search.ts +6 -2
- package/src/web-research/types.ts +4 -2
- package/src/web-research/web-scraper.ts +65 -4
- package/src/core/health-check-service.ts +0 -265
package/src/cli.ts
CHANGED
|
@@ -209,11 +209,24 @@ function makeProgressObserver(): HeadlessObserverOptions {
|
|
|
209
209
|
// ---------------------------------------------------------------------------
|
|
210
210
|
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Thrown by bridgeConfigEnv when an EXPLICIT `--config` file is missing or
|
|
214
|
+
* unreadable. The ambient layers (config.env, cli.env) are optional overlays and
|
|
215
|
+
* stay tolerant — but a file the user named on this exact invocation silently
|
|
216
|
+
* not applying meant a typo'd `--config /path/ci.evn` ran on ambient config and
|
|
217
|
+
* reported success. main() maps this to EXIT.CONFIG for every command that
|
|
218
|
+
* accepts --config.
|
|
219
|
+
*/
|
|
220
|
+
export class ConfigFileError extends Error {}
|
|
221
|
+
|
|
212
222
|
/**
|
|
213
223
|
* Bridge the CLI's config files into process.env for keys not already set in the
|
|
214
224
|
* real environment. Layers the base `~/.pi/research/config.env` then the CLI's
|
|
215
|
-
* own `~/.pi/research/cli.env` overlay (overlay wins)
|
|
216
|
-
*
|
|
225
|
+
* own `~/.pi/research/cli.env` overlay (overlay wins). With an explicit
|
|
226
|
+
* `--config <path>` the NAMED file layers on top of cli.env instead (highest
|
|
227
|
+
* file precedence); it does not replace the base — getConfig() still reads
|
|
228
|
+
* config.env as a lower-precedence file layer, so base keys the named file does
|
|
229
|
+
* not set still apply, and real environment variables always win.
|
|
217
230
|
*
|
|
218
231
|
* Why: auth values (PI_RESEARCH_API_KEY / _PROVIDER) are read directly from
|
|
219
232
|
* process.env, NOT from the typed Config, so a key placed only in a config file
|
|
@@ -234,18 +247,37 @@ export function bridgeConfigEnv(explicitConfigPath?: string): { path: string; lo
|
|
|
234
247
|
// default base, not the cli.env overlay). Project-scoped keys are only
|
|
235
248
|
// bridged for these — see below.
|
|
236
249
|
const fromExplicitBase = new Set<string>();
|
|
237
|
-
|
|
250
|
+
// Layer order decides the winner (later merge wins). Default: base then cli.env
|
|
251
|
+
// (overlay wins). With an explicit --config the NAMED file merges last: a file
|
|
252
|
+
// the user passed on this exact invocation is the most specific intent, and the
|
|
253
|
+
// ambient cli.env overlay silently overriding it re-created the very failure
|
|
254
|
+
// this bridge exists to fix (a key set in `--config ci.env` simply not applying).
|
|
255
|
+
const layers = explicitConfigPath !== undefined
|
|
256
|
+
? [cliOverlayPath, basePath]
|
|
257
|
+
: [basePath, cliOverlayPath];
|
|
258
|
+
for (const filePath of layers) {
|
|
259
|
+
// Only the EXPLICIT --config file fails loudly (see ConfigFileError above);
|
|
260
|
+
// the ambient layers keep their missing/unreadable tolerance unchanged.
|
|
261
|
+
const isExplicit = explicitConfigPath !== undefined && filePath === basePath;
|
|
238
262
|
try {
|
|
239
|
-
if (!fs.existsSync(filePath))
|
|
263
|
+
if (!fs.existsSync(filePath)) {
|
|
264
|
+
if (isExplicit) throw new ConfigFileError(`--config file not found: ${filePath}`);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
240
267
|
const parsed = parseDotEnv(fs.readFileSync(filePath, 'utf-8'));
|
|
241
268
|
Object.assign(merged, parsed);
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
else fromExplicitBase.delete(k);
|
|
269
|
+
if (isExplicit) {
|
|
270
|
+
for (const k of Object.keys(parsed)) fromExplicitBase.add(k);
|
|
245
271
|
}
|
|
246
272
|
loaded = true;
|
|
247
|
-
} catch {
|
|
248
|
-
|
|
273
|
+
} catch (err) {
|
|
274
|
+
if (err instanceof ConfigFileError) throw err;
|
|
275
|
+
if (isExplicit) {
|
|
276
|
+
throw new ConfigFileError(
|
|
277
|
+
`--config file could not be read: ${filePath} (${err instanceof Error ? err.message : String(err)})`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
// ignore unreadable ambient file; fall through to the next
|
|
249
281
|
}
|
|
250
282
|
}
|
|
251
283
|
for (const [k, v] of Object.entries(merged)) {
|
|
@@ -514,7 +546,7 @@ interface ResearchArgs {
|
|
|
514
546
|
* Research subcommand. Initializes the SDK, runs deep/quick research, prints the
|
|
515
547
|
* markdown report to stdout, and always shuts down (even on failure).
|
|
516
548
|
*/
|
|
517
|
-
async function cmdResearch(args: ResearchArgs): Promise<number> {
|
|
549
|
+
export async function cmdResearch(args: ResearchArgs): Promise<number> {
|
|
518
550
|
// Pre-flight with the per-run --model flag folded in: it satisfies the
|
|
519
551
|
// model-required check and steers the provider-aware key detection.
|
|
520
552
|
const det = await detectCredentials(args.model);
|
|
@@ -587,6 +619,18 @@ async function cmdResearch(args: ResearchArgs): Promise<number> {
|
|
|
587
619
|
}
|
|
588
620
|
}
|
|
589
621
|
|
|
622
|
+
// DeepResearchOrchestrator.run() does not throw on cancellation once at least
|
|
623
|
+
// one researcher has produced a report — it returns a fallback synthesis
|
|
624
|
+
// through its normal return path (synthesisService.hasReports branch; see
|
|
625
|
+
// deep-research-orchestrator.ts ~line 584-618). Left unchecked, a Ctrl-C
|
|
626
|
+
// mid-run that still yields a partial report would fall straight through to
|
|
627
|
+
// the unconditional success handling below and report ok:true / exit 0 —
|
|
628
|
+
// indistinguishable from an ordinary completed run. Mirrors reportError's
|
|
629
|
+
// own classification (same exitCodeForSignal formula) for the throwing case.
|
|
630
|
+
if (cancellationRequested) {
|
|
631
|
+
exit = cancellationSignal ? exitCodeForSignal(cancellationSignal) : EXIT.CANCELLED;
|
|
632
|
+
}
|
|
633
|
+
|
|
590
634
|
if (args.json) {
|
|
591
635
|
// Surface the internal quality/health signals that were previously log-only
|
|
592
636
|
// (ISSUES-ANALYSIS-2026-07-09.md, Symptom 4): a caller reading only stdout/JSON
|
|
@@ -599,12 +643,20 @@ async function cmdResearch(args: ResearchArgs): Promise<number> {
|
|
|
599
643
|
depth,
|
|
600
644
|
report,
|
|
601
645
|
...(reportPath ? { reportPath } : {}),
|
|
646
|
+
// The run DID produce a real, complete report — ok stays true. `cancelled`
|
|
647
|
+
// is the qualifier a --json consumer needs to tell "user stopped it, here's
|
|
648
|
+
// what we had" apart from an ordinary finish.
|
|
649
|
+
...(cancellationRequested ? { cancelled: true } : {}),
|
|
602
650
|
...(outcome ? { researchers: { planned: outcome.planned, launched: outcome.launched, succeeded: outcome.succeeded, failed: outcome.failed } } : {}),
|
|
603
651
|
...(errorReport && errorReport.totalErrors > 0 ? {
|
|
604
652
|
trackedErrors: { total: errorReport.totalErrors, uniquePatterns: errorReport.uniquePatterns },
|
|
605
653
|
} : {}),
|
|
606
654
|
}));
|
|
607
655
|
} else {
|
|
656
|
+
// Still a real, useful report — print it as before. The note goes to stderr
|
|
657
|
+
// (alongside the other lifecycle lines) so it never contaminates the report
|
|
658
|
+
// text a plain-mode caller pipes from stdout.
|
|
659
|
+
if (cancellationRequested) toStderr('[pi-research] run was cancelled — showing the partial report.\n');
|
|
608
660
|
toStdout(report.endsWith('\n') ? report : report + '\n');
|
|
609
661
|
}
|
|
610
662
|
} catch (err) {
|
|
@@ -621,7 +673,7 @@ async function cmdResearch(args: ResearchArgs): Promise<number> {
|
|
|
621
673
|
* result; `found: 'no'`/`'maybe'` tells the caller to follow up with live
|
|
622
674
|
* research.
|
|
623
675
|
*/
|
|
624
|
-
async function cmdKnowledge(queries: string[], json?: boolean): Promise<number> {
|
|
676
|
+
export async function cmdKnowledge(queries: string[], json?: boolean): Promise<number> {
|
|
625
677
|
const det = await detectCredentials();
|
|
626
678
|
if (det.problem) {
|
|
627
679
|
if (json) { toStdout(pretty({ ok: false, error: det.problem, exitCode: EXIT.CONFIG })); return EXIT.CONFIG; }
|
|
@@ -636,7 +688,7 @@ async function cmdKnowledge(queries: string[], json?: boolean): Promise<number>
|
|
|
636
688
|
'Knowledge store is disabled (PI_RESEARCH_KNOWLEDGE_STORE_MODE=none). ' +
|
|
637
689
|
"Set it to 'project' or 'global' to enable local knowledge search.\n\n" +
|
|
638
690
|
`Configure in:\n ${paths.configEnv}\n ${paths.cliIfaceEnv} (cli overlay)\n or via PI_RESEARCH_KNOWLEDGE_STORE_MODE env var`;
|
|
639
|
-
if (json) toStdout(pretty({ found: 'no', text: msg, configured: false }));
|
|
691
|
+
if (json) toStdout(pretty({ ok: true, found: 'no', text: msg, configured: false }));
|
|
640
692
|
else toStderr(`\n[pi-research] ${msg}\n`);
|
|
641
693
|
return EXIT.CONFIG;
|
|
642
694
|
}
|
|
@@ -660,7 +712,10 @@ async function cmdKnowledge(queries: string[], json?: boolean): Promise<number>
|
|
|
660
712
|
}
|
|
661
713
|
|
|
662
714
|
if (json) {
|
|
663
|
-
|
|
715
|
+
// `ok: true` makes `ok` a reliable success/failure discriminant across every
|
|
716
|
+
// knowledge JSON variant (disabled-store above, reportError's ok:false below,
|
|
717
|
+
// and this success shape) — additive only, every existing field is unchanged.
|
|
718
|
+
toStdout(pretty({ ok: true, ...result }));
|
|
664
719
|
} else {
|
|
665
720
|
toStdout((result.text.endsWith('\n') ? result.text : result.text + '\n'));
|
|
666
721
|
}
|
|
@@ -715,51 +770,60 @@ async function cmdStatus(json?: boolean): Promise<number> {
|
|
|
715
770
|
* setting on the user's behalf without hand-editing the locked registry JSON. A machine-wide
|
|
716
771
|
* default is set instead via the PI_RESEARCH_KNOWLEDGE_STORE_MODE env var (per-run) or config.env.
|
|
717
772
|
*/
|
|
718
|
-
async function cmdKnowledgeConfig(kc: NonNullable<ParsedArgs['knowledgeConfig']>): Promise<number> {
|
|
773
|
+
export async function cmdKnowledgeConfig(kc: NonNullable<ParsedArgs['knowledgeConfig']>): Promise<number> {
|
|
719
774
|
const cwd = process.cwd();
|
|
720
775
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
776
|
+
// Wrapped like cmdKnowledge's own try/catch → reportError: without this, a
|
|
777
|
+
// thrown error (e.g. saveConfig failing to write the registry) escaped all the
|
|
778
|
+
// way to main()'s top-level catch, which ALWAYS writes plain text to stderr —
|
|
779
|
+
// silently breaking the documented `--json: Emit a JSON object` contract for
|
|
780
|
+
// this one subcommand.
|
|
781
|
+
try {
|
|
782
|
+
if (kc.action === 'set') {
|
|
783
|
+
const before = getConfig(cwd, 'cli').KNOWLEDGE_STORE_MODE;
|
|
784
|
+
const cfg = { ...getConfig(cwd, 'cli'), KNOWLEDGE_STORE_MODE: kc.mode! };
|
|
785
|
+
// Persist ONLY the knowledge-store mode for this directory — not the whole local-key set —
|
|
786
|
+
// so an unrelated per-directory DEFAULT_RESEARCH_DEPTH isn't frozen as a side effect.
|
|
787
|
+
saveConfig(cfg, 'local', cwd, ['PI_RESEARCH_KNOWLEDGE_STORE_MODE']); // per-directory registry write
|
|
788
|
+
resetConfig(); // drop the cached config so `after` re-resolves from disk
|
|
789
|
+
const info = describeKnowledgeStoreMode(cwd, 'cli');
|
|
790
|
+
// The write always persists to the registry, but a real env var out-ranks the registry, so
|
|
791
|
+
// the EFFECTIVE mode can still differ from what was requested. Surface that instead of
|
|
792
|
+
// silently reporting a no-op.
|
|
793
|
+
const overridden = info.mode !== kc.mode;
|
|
794
|
+
if (kc.json) {
|
|
795
|
+
toStdout(pretty({ command: 'knowledge-config', action: 'set', requested: kc.mode, previous: before, saved: true, effectiveOverriddenBy: overridden ? info.origin : null, ...info, cwd }));
|
|
796
|
+
return EXIT.OK;
|
|
797
|
+
}
|
|
798
|
+
toStdout(
|
|
799
|
+
`knowledge store mode for this directory: ${before} -> ${kc.mode} (saved per-directory)\n` +
|
|
800
|
+
` scope: ${kc.mode === 'none' ? 'disabled here' : kc.mode === 'project' ? 'this directory only' : 'shared across all directories'}\n` +
|
|
801
|
+
` saved: per-directory (${cwd})\n` +
|
|
802
|
+
` db dir: ${info.dbDir}\n` +
|
|
803
|
+
(overridden
|
|
804
|
+
? `\nNOTE: the effective mode here is still '${info.mode}' because a higher-precedence ${info.origin} overrides it. Clear that source for this setting to take effect.\n`
|
|
805
|
+
: ''),
|
|
806
|
+
);
|
|
807
|
+
return EXIT.OK;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// show
|
|
728
811
|
const info = describeKnowledgeStoreMode(cwd, 'cli');
|
|
729
|
-
// The write always persists to the registry, but a real env var out-ranks the registry, so
|
|
730
|
-
// the EFFECTIVE mode can still differ from what was requested. Surface that instead of
|
|
731
|
-
// silently reporting a no-op.
|
|
732
|
-
const overridden = info.mode !== kc.mode;
|
|
733
812
|
if (kc.json) {
|
|
734
|
-
toStdout(pretty({ command: 'knowledge-config', action: '
|
|
813
|
+
toStdout(pretty({ command: 'knowledge-config', action: 'show', ...info, cwd }));
|
|
735
814
|
return EXIT.OK;
|
|
736
815
|
}
|
|
737
816
|
toStdout(
|
|
738
|
-
`knowledge store mode
|
|
739
|
-
` scope: ${
|
|
740
|
-
` saved: per-directory (${cwd})\n` +
|
|
817
|
+
`knowledge store mode: ${info.mode} (source: ${info.origin})\n` +
|
|
818
|
+
` scope: ${info.mode === 'none' ? 'disabled here' : info.mode === 'project' ? 'this directory only' : 'shared across all directories'}\n` +
|
|
741
819
|
` db dir: ${info.dbDir}\n` +
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
: ''),
|
|
820
|
+
`\nchange it for THIS directory: ${BINARY_NAME} knowledge-config set <none|project|global>\n` +
|
|
821
|
+
`change the machine-wide default: set PI_RESEARCH_KNOWLEDGE_STORE_MODE=<mode> in ${resolvedConfigPaths().configEnv}\n`,
|
|
745
822
|
);
|
|
746
823
|
return EXIT.OK;
|
|
824
|
+
} catch (err) {
|
|
825
|
+
return reportError(err, 'knowledge-config', kc.json);
|
|
747
826
|
}
|
|
748
|
-
|
|
749
|
-
// show
|
|
750
|
-
const info = describeKnowledgeStoreMode(cwd, 'cli');
|
|
751
|
-
if (kc.json) {
|
|
752
|
-
toStdout(pretty({ command: 'knowledge-config', action: 'show', ...info, cwd }));
|
|
753
|
-
return EXIT.OK;
|
|
754
|
-
}
|
|
755
|
-
toStdout(
|
|
756
|
-
`knowledge store mode: ${info.mode} (source: ${info.origin})\n` +
|
|
757
|
-
` scope: ${info.mode === 'none' ? 'disabled here' : info.mode === 'project' ? 'this directory only' : 'shared across all directories'}\n` +
|
|
758
|
-
` db dir: ${info.dbDir}\n` +
|
|
759
|
-
`\nchange it for THIS directory: ${BINARY_NAME} knowledge-config set <none|project|global>\n` +
|
|
760
|
-
`change the machine-wide default: set PI_RESEARCH_KNOWLEDGE_STORE_MODE=<mode> in ${resolvedConfigPaths().configEnv}\n`,
|
|
761
|
-
);
|
|
762
|
-
return EXIT.OK;
|
|
763
827
|
}
|
|
764
828
|
|
|
765
829
|
// ---------------------------------------------------------------------------
|
|
@@ -1058,21 +1122,25 @@ COMMANDS
|
|
|
1058
1122
|
--model <provider/id> Model for this run (outranks PI_RESEARCH_MODEL; satisfies the required-model check).
|
|
1059
1123
|
--exclude-tools <a,b> Disable internal tools (e.g. security_search,stackexchange).
|
|
1060
1124
|
--initial-links <url ...> Seed URLs to investigate first; requires a query (-- ends options).
|
|
1061
|
-
--config <path>
|
|
1125
|
+
--config <path> Layer this config file on top (highest file precedence; base
|
|
1126
|
+
config.env keys it does not set still apply; env vars still win).
|
|
1062
1127
|
--json Emit a JSON object instead of a markdown report.
|
|
1063
1128
|
|
|
1064
1129
|
knowledge "<q1>" ["<q2>" ...] Search local knowledge store before live research (up to 5 queries).
|
|
1065
|
-
--config <path>
|
|
1130
|
+
--config <path> Layer this config file on top (highest file precedence; base
|
|
1131
|
+
config.env keys it does not set still apply; env vars still win).
|
|
1066
1132
|
--json Emit a JSON object.
|
|
1067
1133
|
|
|
1068
1134
|
knowledge-config [show] Show the knowledge-store mode for this directory + its source.
|
|
1069
|
-
--config <path>
|
|
1135
|
+
--config <path> Layer this config file on top (highest file precedence; base
|
|
1136
|
+
config.env keys it does not set still apply; env vars still win).
|
|
1070
1137
|
--json Emit a JSON object.
|
|
1071
1138
|
knowledge-config set <mode> Set the mode for THIS directory (none | project | global);
|
|
1072
1139
|
persisted per-directory. Takes effect on the next run.
|
|
1073
1140
|
|
|
1074
1141
|
status Show detected config, model/key, and readiness.
|
|
1075
|
-
--config <path>
|
|
1142
|
+
--config <path> Layer this config file on top (highest file precedence; base
|
|
1143
|
+
config.env keys it does not set still apply; env vars still win).
|
|
1076
1144
|
--json Emit a JSON object.
|
|
1077
1145
|
|
|
1078
1146
|
skill [status] Show where the agent skill is installed across your coding agents.
|
|
@@ -1118,6 +1186,20 @@ EXIT CODES
|
|
|
1118
1186
|
`;
|
|
1119
1187
|
}
|
|
1120
1188
|
|
|
1189
|
+
/**
|
|
1190
|
+
* Read a value-taking option's argument, rejecting the next FLAG as a value.
|
|
1191
|
+
* Truthiness alone let `--model --json` run a full research session against the
|
|
1192
|
+
* literal model "--json" (failing minutes-deep at exit 70 with the --json output
|
|
1193
|
+
* contract silently dropped) instead of failing fast at exit 64.
|
|
1194
|
+
*/
|
|
1195
|
+
function takeOptionValue(rest: (string | undefined)[], valueIndex: number, flag: string): string {
|
|
1196
|
+
const v = rest[valueIndex];
|
|
1197
|
+
if (v === undefined || v.startsWith('--')) {
|
|
1198
|
+
throw new UsageError(`${flag} requires a value.`);
|
|
1199
|
+
}
|
|
1200
|
+
return v;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1121
1203
|
export function parseArgs(argv: string[]): ParsedArgs {
|
|
1122
1204
|
const out: ParsedArgs = {};
|
|
1123
1205
|
const args = argv.slice(2);
|
|
@@ -1140,10 +1222,21 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
1140
1222
|
const rest = args.slice(1);
|
|
1141
1223
|
|
|
1142
1224
|
if (cmd === 'status') {
|
|
1143
|
-
|
|
1225
|
+
// Same unknown-option rejection loop as the other subcommands. The previous
|
|
1226
|
+
// ad-hoc rest.includes('--json') accepted anything — `status --jsn` and
|
|
1227
|
+
// `status --json=true` exited 0 with human output instead of failing fast.
|
|
1228
|
+
let json = false;
|
|
1144
1229
|
let configPath: string | undefined;
|
|
1145
|
-
|
|
1146
|
-
|
|
1230
|
+
for (let i = 0; i < rest.length; i++) {
|
|
1231
|
+
const a = rest[i];
|
|
1232
|
+
if (a === '--json') json = true;
|
|
1233
|
+
else if (a === '--config') configPath = takeOptionValue(rest, ++i, '--config');
|
|
1234
|
+
else if (a?.startsWith('--')) {
|
|
1235
|
+
throw new UsageError(`unknown option for status: ${a}. Valid options: --json, --config <path>.`);
|
|
1236
|
+
} else if (a !== undefined) {
|
|
1237
|
+
throw new UsageError(`unexpected argument "${a}" after "status".`);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1147
1240
|
out.command = 'status';
|
|
1148
1241
|
out.status = { json };
|
|
1149
1242
|
out.configPath = configPath;
|
|
@@ -1157,7 +1250,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
1157
1250
|
for (let i = 0; i < rest.length; i++) {
|
|
1158
1251
|
const a = rest[i];
|
|
1159
1252
|
if (a === '--json') json = true;
|
|
1160
|
-
else if (a === '--config'
|
|
1253
|
+
else if (a === '--config') configPath = takeOptionValue(rest, ++i, '--config');
|
|
1161
1254
|
else if (a?.startsWith('--')) {
|
|
1162
1255
|
throw new UsageError(`unknown option for knowledge: ${a}`);
|
|
1163
1256
|
} else if (a !== undefined) {
|
|
@@ -1179,7 +1272,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
1179
1272
|
for (let i = 0; i < rest.length; i++) {
|
|
1180
1273
|
const a = rest[i];
|
|
1181
1274
|
if (a === '--json') json = true;
|
|
1182
|
-
else if (a === '--config'
|
|
1275
|
+
else if (a === '--config') configPath = takeOptionValue(rest, ++i, '--config');
|
|
1183
1276
|
else if (a?.startsWith('--')) {
|
|
1184
1277
|
throw new UsageError(`unknown option for knowledge-config: ${a}`);
|
|
1185
1278
|
} else if (a !== undefined) {
|
|
@@ -1263,11 +1356,11 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
1263
1356
|
if (isNaN(n) || n < 0 || n > 3) throw new UsageError('--depth must be an integer 0–3.');
|
|
1264
1357
|
depth = n;
|
|
1265
1358
|
} else if (a === '--model') {
|
|
1266
|
-
model = rest
|
|
1267
|
-
if (!model) throw new UsageError('--model requires a value.');
|
|
1359
|
+
model = takeOptionValue(rest, ++i, '--model');
|
|
1268
1360
|
} else if (a === '--exclude-tools') {
|
|
1269
|
-
const v = rest
|
|
1270
|
-
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;
|
|
1271
1364
|
} else if (a === '--initial-links') {
|
|
1272
1365
|
// Consume until the next flag or end. Validate each entry: these are templated
|
|
1273
1366
|
// verbatim into the researcher's "investigate these first" evidence block, so
|
|
@@ -1289,8 +1382,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
1289
1382
|
}
|
|
1290
1383
|
i = j - 1;
|
|
1291
1384
|
} else if (a === '--config') {
|
|
1292
|
-
configPath = rest
|
|
1293
|
-
if (!configPath) throw new UsageError('--config requires a path.');
|
|
1385
|
+
configPath = takeOptionValue(rest, ++i, '--config');
|
|
1294
1386
|
} else if (a === '--json') {
|
|
1295
1387
|
json = true;
|
|
1296
1388
|
} else if (a?.startsWith('--')) {
|
|
@@ -1380,14 +1472,63 @@ export function _markCancellationForTests(sig?: NodeJS.Signals): void {
|
|
|
1380
1472
|
cancellationSignal = sig ?? null;
|
|
1381
1473
|
}
|
|
1382
1474
|
|
|
1475
|
+
/**
|
|
1476
|
+
* When the FIRST cancelling signal was handled (epoch ms), or null before one
|
|
1477
|
+
* arrives. Module-scoped rather than a main() closure so the duplicate-vs-force
|
|
1478
|
+
* decision below is directly testable through the seams underneath.
|
|
1479
|
+
*/
|
|
1480
|
+
let firstSignalAtMs: number | null = null;
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* How long after the first signal a repeat still reads as a MECHANICAL
|
|
1484
|
+
* duplicate. The skill launcher forwards every cancelling signal to this
|
|
1485
|
+
* process — but with stdio:'inherit' a tty Ctrl-C has ALREADY delivered SIGINT
|
|
1486
|
+
* to the whole foreground group, so the forwarded copy lands within
|
|
1487
|
+
* milliseconds of the first. A human's second Ctrl-C at a stuck cleanup comes
|
|
1488
|
+
* seconds later; 1s cleanly separates the two.
|
|
1489
|
+
*/
|
|
1490
|
+
const SIGNAL_FORCE_EXIT_GRACE_MS = 1_000;
|
|
1491
|
+
|
|
1492
|
+
export type SignalDisposition = 'begin-cleanup' | 'absorb-duplicate' | 'force-exit';
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* Decide what a delivered cancelling signal means, latching the first arrival.
|
|
1496
|
+
* Exported as the test seam for the duplicate-absorption contract: the race
|
|
1497
|
+
* windows (a forwarded duplicate landing mid-safeShutdown) cannot be timed
|
|
1498
|
+
* reliably through a spawned CLI, so tests drive the decision directly —
|
|
1499
|
+
* mirroring the _markCancellationForTests seam above.
|
|
1500
|
+
*/
|
|
1501
|
+
export function _signalDisposition(now: number = Date.now()): SignalDisposition {
|
|
1502
|
+
if (firstSignalAtMs === null) {
|
|
1503
|
+
firstSignalAtMs = now;
|
|
1504
|
+
return 'begin-cleanup';
|
|
1505
|
+
}
|
|
1506
|
+
return now - firstSignalAtMs > SIGNAL_FORCE_EXIT_GRACE_MS ? 'force-exit' : 'absorb-duplicate';
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
export function _resetSignalDispositionForTests(): void {
|
|
1510
|
+
firstSignalAtMs = null;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1383
1513
|
async function main(argv: string[]): Promise<number> {
|
|
1384
1514
|
// Register signal handlers so SIGINT/SIGTERM always trigger SDK shutdown
|
|
1385
1515
|
// before the process exits. safeShutdown() is idempotent — safe to call
|
|
1386
1516
|
// before SDK init (it no-ops) and safe to call from multiple signals.
|
|
1387
|
-
let _signalCleanupDone = false;
|
|
1388
1517
|
const onSignal = (sig: string) => {
|
|
1389
|
-
|
|
1390
|
-
|
|
1518
|
+
switch (_signalDisposition()) {
|
|
1519
|
+
case 'absorb-duplicate':
|
|
1520
|
+
// The launcher's forwarded copy of a signal the tty already delivered
|
|
1521
|
+
// to the whole foreground group — cleanup is running; swallow it.
|
|
1522
|
+
return;
|
|
1523
|
+
case 'force-exit':
|
|
1524
|
+
// A repeat WELL after the first is a human (or an escalating harness)
|
|
1525
|
+
// at a stuck cleanup: honour it immediately, on the true 128+N.
|
|
1526
|
+
toStderr(`\n[pi-research] ${sig} again — force exit\n`);
|
|
1527
|
+
process.exit(exitCodeForSignal(sig as NodeJS.Signals));
|
|
1528
|
+
return;
|
|
1529
|
+
case 'begin-cleanup':
|
|
1530
|
+
break;
|
|
1531
|
+
}
|
|
1391
1532
|
cancellationRequested = true;
|
|
1392
1533
|
cancellationSignal = sig as NodeJS.Signals;
|
|
1393
1534
|
toStderr(`\n[pi-research] ${sig} — cleaning up…\n`);
|
|
@@ -1404,8 +1545,15 @@ async function main(argv: string[]): Promise<number> {
|
|
|
1404
1545
|
// exit status depend on whether this handler beat a force-kill.
|
|
1405
1546
|
void safeShutdown().finally(() => flushAndExit(exitCodeForSignal(sig as NodeJS.Signals)));
|
|
1406
1547
|
};
|
|
1407
|
-
process.
|
|
1408
|
-
|
|
1548
|
+
// process.on, NOT process.once: a once-handler is CONSUMED by its first
|
|
1549
|
+
// delivery, restoring the default disposition — and the launcher forwards
|
|
1550
|
+
// every cancelling signal it receives, so a tty Ctrl-C (delivered group-wide
|
|
1551
|
+
// by the terminal AND re-forwarded by the launcher) had its duplicate kill
|
|
1552
|
+
// this process instantly, mid-safeShutdown, skipping browser/embedder
|
|
1553
|
+
// teardown. Persistent handlers absorb duplicates via _signalDisposition,
|
|
1554
|
+
// which still preserves a human force-quit escape after the grace window.
|
|
1555
|
+
process.on('SIGINT', () => onSignal('SIGINT'));
|
|
1556
|
+
process.on('SIGTERM', () => onSignal('SIGTERM'));
|
|
1409
1557
|
// SIGHUP (terminal closed) and, on Windows, SIGBREAK (Ctrl+Break). Without these
|
|
1410
1558
|
// the run dies without cancelling: no abort of the in-flight research, no browser
|
|
1411
1559
|
// or embedding-server teardown, and no 128+N exit code. On Windows it matters
|
|
@@ -1414,9 +1562,9 @@ async function main(argv: string[]): Promise<number> {
|
|
|
1414
1562
|
// The SDK already registers both (see _addSignalHandlers); the CLI did not, which
|
|
1415
1563
|
// left the SDK's handler to tear the container down underneath a live orchestrator
|
|
1416
1564
|
// — exactly the race the CLI installs its own handlers to avoid.
|
|
1417
|
-
process.
|
|
1565
|
+
process.on('SIGHUP', () => onSignal('SIGHUP'));
|
|
1418
1566
|
if (process.platform === 'win32') {
|
|
1419
|
-
process.
|
|
1567
|
+
process.on('SIGBREAK', () => onSignal('SIGBREAK'));
|
|
1420
1568
|
}
|
|
1421
1569
|
|
|
1422
1570
|
let parsed: ParsedArgs;
|
|
@@ -1432,7 +1580,17 @@ async function main(argv: string[]): Promise<number> {
|
|
|
1432
1580
|
// help/version are pure and need no credentials/config.
|
|
1433
1581
|
const noBridge = new Set(['help', 'version']);
|
|
1434
1582
|
if (!noBridge.has(parsed.command ?? '')) {
|
|
1435
|
-
|
|
1583
|
+
try {
|
|
1584
|
+
bridgeConfigEnv(parsed.configPath);
|
|
1585
|
+
} catch (err) {
|
|
1586
|
+
if (err instanceof ConfigFileError) {
|
|
1587
|
+
// An explicit --config that does not apply is a setup error: silently
|
|
1588
|
+
// running on ambient config and reporting success would mask the typo.
|
|
1589
|
+
toStderr(`\nError: ${err.message}\n`);
|
|
1590
|
+
return EXIT.CONFIG;
|
|
1591
|
+
}
|
|
1592
|
+
throw err;
|
|
1593
|
+
}
|
|
1436
1594
|
// Self-heal cross-harness skill installs on every engine-touching CLI run. The
|
|
1437
1595
|
// CLI — not the interactive pi extension — is the surface that skill invocations
|
|
1438
1596
|
// from Claude Code / Codex / OpenClaw actually drive, so without this an update
|
package/src/config.ts
CHANGED
|
@@ -302,6 +302,82 @@ function sleepSync(ms: number): void {
|
|
|
302
302
|
* could both read the same pre-change snapshot and clobber each other's entry (a lost update).
|
|
303
303
|
* `mutate` receives the latest on-disk registry and edits it in place.
|
|
304
304
|
*/
|
|
305
|
+
/**
|
|
306
|
+
* Age past which a leftover reclaim file (see the failed-restore branch below) is
|
|
307
|
+
* swept. Well beyond the 30s lock-staleness threshold, so a reclaim file another
|
|
308
|
+
* contender is still inspecting mid-steal is never yanked out from under it.
|
|
309
|
+
*/
|
|
310
|
+
const RECLAIM_FILE_SWEEP_AGE_MS = 120_000;
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Sweep reclaim files left behind by an earlier steal whose ABA restore lost the
|
|
314
|
+
* path race (that branch leaves them in place ON PURPOSE — see below). Runs only
|
|
315
|
+
* at steal time, which is already the rare contended path, so normal acquires
|
|
316
|
+
* pay nothing.
|
|
317
|
+
*/
|
|
318
|
+
function sweepLeftoverReclaimFiles(lockPath: string): void {
|
|
319
|
+
try {
|
|
320
|
+
const dir = path.dirname(lockPath);
|
|
321
|
+
const prefix = `${path.basename(lockPath)}.stale-`;
|
|
322
|
+
for (const name of fs.readdirSync(dir)) {
|
|
323
|
+
if (!name.startsWith(prefix)) continue;
|
|
324
|
+
const p = path.join(dir, name);
|
|
325
|
+
try {
|
|
326
|
+
if (Date.now() - fs.statSync(p).mtimeMs > RECLAIM_FILE_SWEEP_AGE_MS) fs.unlinkSync(p);
|
|
327
|
+
} catch { /* raced with another sweeper — ignore */ }
|
|
328
|
+
}
|
|
329
|
+
} catch { /* hygiene only — never block the steal */ }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Reclaim a lock file already judged stale, without the unlink race.
|
|
334
|
+
*
|
|
335
|
+
* A direct `unlinkSync(lockPath)` lets two contenders both judge the SAME lock
|
|
336
|
+
* stale: the slower one then unlinks the winner's freshly created lock, and both
|
|
337
|
+
* enter the critical section — the exact lost update the lock exists to prevent.
|
|
338
|
+
* Rename is atomic, so exactly one contender wins the steal; the inode check then
|
|
339
|
+
* catches the narrower ABA case where the path already holds a NEW lock created
|
|
340
|
+
* after the caller's stat, and `linkSync` restores it without clobbering (link
|
|
341
|
+
* fails EEXIST if yet another lock took the path meanwhile).
|
|
342
|
+
*
|
|
343
|
+
* Callers always `continue` their O_EXCL acquire loop afterwards — this helper
|
|
344
|
+
* never grants the lock, it only makes the steal single-winner.
|
|
345
|
+
*/
|
|
346
|
+
function stealStaleLock(lockPath: string, judgedStale: fs.Stats): void {
|
|
347
|
+
sweepLeftoverReclaimFiles(lockPath);
|
|
348
|
+
const reclaimPath = `${lockPath}.stale-${process.pid}-${Date.now()}`;
|
|
349
|
+
try {
|
|
350
|
+
fs.renameSync(lockPath, reclaimPath);
|
|
351
|
+
} catch {
|
|
352
|
+
return; // Another contender already stole it — re-enter the acquire loop.
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
const claimed = fs.statSync(reclaimPath);
|
|
356
|
+
if (claimed.ino !== judgedStale.ino) {
|
|
357
|
+
// We grabbed a lock created after our staleness judgment — put it back.
|
|
358
|
+
try {
|
|
359
|
+
fs.linkSync(reclaimPath, lockPath);
|
|
360
|
+
} catch {
|
|
361
|
+
// A newer lock claimed the path before the restore (EEXIST). Do NOT
|
|
362
|
+
// unlink the reclaim file: it is the displaced FRESH lock's inode —
|
|
363
|
+
// falling through to the unlink below permanently destroyed a live
|
|
364
|
+
// holder's lock (and the post-mortem evidence of this race). Leaving it
|
|
365
|
+
// costs one stale file, collected by the age-gated sweep above.
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
} catch { /* ignore */ }
|
|
370
|
+
try { fs.unlinkSync(reclaimPath); } catch { /* ignore */ }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Test seam: stealStaleLock's race windows (a contender's O_EXCL create landing
|
|
375
|
+
* between the rename and the linkSync restore) cannot be interleaved through the
|
|
376
|
+
* public save/update API — the code is synchronous with no yield points — so
|
|
377
|
+
* tests drive the helper directly.
|
|
378
|
+
*/
|
|
379
|
+
export const _stealStaleLockForTests = stealStaleLock;
|
|
380
|
+
|
|
305
381
|
function updateProjectSettingsRegistry(mutate: (registry: Record<string, Record<string, string>>) => void): void {
|
|
306
382
|
const registryPath = getProjectSettingsRegistryPath();
|
|
307
383
|
const dir = path.dirname(registryPath);
|
|
@@ -327,7 +403,7 @@ function updateProjectSettingsRegistry(mutate: (registry: Record<string, Record<
|
|
|
327
403
|
try {
|
|
328
404
|
const stats = fs.statSync(lockPath);
|
|
329
405
|
if (Date.now() - stats.mtimeMs > 30000) {
|
|
330
|
-
|
|
406
|
+
stealStaleLock(lockPath, stats);
|
|
331
407
|
continue;
|
|
332
408
|
}
|
|
333
409
|
} catch { /* ignore */ }
|
|
@@ -841,7 +917,7 @@ export function saveConfig(config: Config, scope: 'local' | 'user' = 'local', cw
|
|
|
841
917
|
try {
|
|
842
918
|
const stats = fs.statSync(lockPath);
|
|
843
919
|
if (Date.now() - stats.mtimeMs > 30000) {
|
|
844
|
-
|
|
920
|
+
stealStaleLock(lockPath, stats);
|
|
845
921
|
continue;
|
|
846
922
|
}
|
|
847
923
|
} catch { /* ignore */ }
|
|
@@ -5,46 +5,10 @@
|
|
|
5
5
|
import type { IService } from '../service-registry.ts';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Health check
|
|
8
|
+
* Health check registry interface
|
|
9
9
|
*/
|
|
10
|
-
export interface
|
|
11
|
-
success: boolean;
|
|
12
|
-
searchOk: boolean;
|
|
13
|
-
scrapeOk: boolean;
|
|
14
|
-
error?: string;
|
|
15
|
-
timestamp: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Health check cache state
|
|
20
|
-
*/
|
|
21
|
-
export interface HealthCheckCache {
|
|
22
|
-
pending: Promise<HealthCheckResult> | null;
|
|
23
|
-
failureCount: number;
|
|
24
|
-
backoffUntil: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Health check service interface
|
|
29
|
-
*/
|
|
30
|
-
export interface IHealthCheckService extends IService {
|
|
31
|
-
getPendingCheck(): Promise<HealthCheckResult> | null;
|
|
32
|
-
setPendingCheck(promise: Promise<HealthCheckResult> | null): void;
|
|
33
|
-
getFailureCount(): number;
|
|
34
|
-
incrementFailureCount(): void;
|
|
35
|
-
resetFailureCount(): void;
|
|
36
|
-
getBackoffUntil(): number;
|
|
37
|
-
setBackoffUntil(timestamp: number): void;
|
|
38
|
-
clear(): void;
|
|
39
|
-
isBackoffActive(): boolean;
|
|
40
|
-
getBackoffRemainingMs(): number;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Health check registry interface
|
|
45
|
-
*/
|
|
46
|
-
export interface IHealthRegistryService extends IService {
|
|
10
|
+
export interface IHealthRegistryService extends IService {
|
|
47
11
|
register(name: string, check: (options?: { force?: boolean }) => Promise<{ healthy: boolean; error?: string; diagnostic?: Record<string, any> }>, options?: { timeoutMs?: number; critical?: boolean }): void;
|
|
48
12
|
isCritical(componentName: string): boolean;
|
|
49
13
|
runAll(options?: { force?: boolean }): Promise<{ status: 'healthy' | 'degraded' | 'unhealthy'; components: any[]; timestamp: string }>;
|
|
50
|
-
|
|
14
|
+
}
|
|
@@ -66,6 +66,14 @@ export interface UpdatePlanOptions {
|
|
|
66
66
|
previousPlan: ResearchPlan | null;
|
|
67
67
|
totalResearchersPlanned: number;
|
|
68
68
|
mustSynthesize?: boolean;
|
|
69
|
+
/** The caller's live round budget (base complexity max plus any steering-driven
|
|
70
|
+
* extension), e.g. from DeepResearchOrchestrator's extended `maxRounds`. When
|
|
71
|
+
* omitted, updatePlanForRound falls back to the base complexity-table value —
|
|
72
|
+
* which understates the budget once steering has extended it, skewing the
|
|
73
|
+
* round-phase-guidance ratio (and its EARLY/MIDDLE/LATE branch selection)
|
|
74
|
+
* toward LATE too early. Optional and additive: existing callers that don't
|
|
75
|
+
* pass it keep the prior behavior unchanged. */
|
|
76
|
+
maxRounds?: number;
|
|
69
77
|
observer?: ResearchObserver;
|
|
70
78
|
excludeTools?: string[];
|
|
71
79
|
steeringMessages?: string[];
|