pi-background-tasks 1.0.7 → 2.1.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.
Files changed (55) hide show
  1. package/README.md +8 -8
  2. package/TESTING.md +3 -3
  3. package/TEST_PLAN.md +6 -6
  4. package/docs/INDEX.md +25 -25
  5. package/docs/choose-a-workflow.md +4 -4
  6. package/docs/commands/bg-clear.md +1 -1
  7. package/docs/commands/bg-update.md +1 -1
  8. package/docs/commands/bg.md +1 -1
  9. package/docs/commands/fusion-models.md +1 -1
  10. package/docs/commands/fusion.md +5 -8
  11. package/docs/commands/jobs.md +1 -1
  12. package/docs/commands/kill.md +1 -1
  13. package/docs/commands/logs.md +1 -1
  14. package/docs/commands/task-manager.md +2 -2
  15. package/docs/concepts/completion-delivery.md +1 -0
  16. package/docs/getting-started.md +1 -1
  17. package/docs/manifest.json +69 -50
  18. package/docs/operations/configuration.md +5 -3
  19. package/docs/read-before-edit.md +3 -0
  20. package/docs/reference/runtime-contracts.md +87 -82
  21. package/docs/reference/shortcuts-and-dock.md +2 -2
  22. package/docs/subsystems/background-task-runtime.md +7 -1
  23. package/docs/subsystems/docs-freshness-gate.md +5 -5
  24. package/docs/subsystems/fusion.md +22 -15
  25. package/docs/subsystems/host-ui-and-telemetry.md +1 -1
  26. package/docs/tools/bg_delegate.md +1 -1
  27. package/docs/tools/bg_kill.md +1 -1
  28. package/docs/tools/bg_logs.md +1 -1
  29. package/docs/tools/bg_result.md +14 -10
  30. package/docs/tools/bg_run.md +1 -1
  31. package/docs/tools/bg_run_pi_attested.md +1 -1
  32. package/docs/tools/bg_status.md +1 -1
  33. package/docs/tools/fusion_investigate.md +6 -4
  34. package/docs/tools/fusion_reason.md +5 -5
  35. package/docs/tools/fusion_research.md +6 -2
  36. package/docs/tools/fusion_validate.md +5 -3
  37. package/package.json +1 -1
  38. package/src/core/common.ts +50 -2
  39. package/src/core/fusion/anthropic-attribution.ts +1930 -0
  40. package/src/core/fusion/artifacts.ts +168 -21
  41. package/src/core/fusion/budget.ts +23 -23
  42. package/src/core/fusion/child-protocol.ts +115 -10
  43. package/src/core/fusion/claude-cache.ts +21 -0
  44. package/src/core/fusion/config.ts +10 -2
  45. package/src/core/fusion/orchestrator.ts +281 -77
  46. package/src/core/fusion/output-contract.ts +34 -0
  47. package/src/core/fusion/pi-child.ts +420 -12
  48. package/src/core/fusion/prompts.ts +11 -1
  49. package/src/core/fusion/result-package.ts +412 -0
  50. package/src/core/fusion/types.ts +67 -0
  51. package/src/core/registry.ts +187 -20
  52. package/src/delegate-extension.ts +130 -24
  53. package/src/extension.ts +17 -6
  54. package/src/fusion-child-extension.ts +117 -2
  55. package/src/fusion-extension.ts +308 -154
@@ -8,7 +8,7 @@ import {
8
8
  readFileSync,
9
9
  writeSync,
10
10
  } from 'node:fs';
11
- import { dirname } from 'node:path';
11
+ import { dirname, isAbsolute } from 'node:path';
12
12
  import { parseJsonText } from './core/common.js';
13
13
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
14
14
  import { Type, type Static } from 'typebox';
@@ -32,11 +32,13 @@ import {
32
32
  parseFusionSourcePolicy,
33
33
  } from './core/fusion/source-policy.js';
34
34
  import {
35
+ applyFusionClaudePromptCachingScopeHeader,
35
36
  nonAnthropicFusionCacheObservation,
36
37
  normalizeFusionClaudeCachePayload,
37
38
  type FusionClaudeCacheObservation,
38
39
  } from './core/fusion/claude-cache.js';
39
40
  import {
41
+ FUSION_CANDIDATE_OUTPUT_RECOVERY_PATH_ENV,
40
42
  FUSION_CHILD_MAX_PROVIDER_REQUESTS,
41
43
  FUSION_CHILD_MAX_TOOL_CALLS,
42
44
  FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES,
@@ -59,12 +61,19 @@ import {
59
61
  type FusionRuntimeGuardCode,
60
62
  type FusionRuntimeGuardRecord,
61
63
  } from './core/fusion/child-protocol.js';
64
+ import {
65
+ FUSION_CANDIDATE_MAX_OUTPUT_BYTES,
66
+ FUSION_CANDIDATE_OUTPUT_COMPRESSION_PROMPT,
67
+ fusionJsonRenderedTextBytes,
68
+ } from './core/fusion/output-contract.js';
62
69
 
63
70
  export {
64
71
  FUSION_CLAUDE_CACHE_BREAKPOINT_LIMIT,
65
72
  FUSION_CLAUDE_CACHE_DEFAULT_RETENTION,
66
73
  FUSION_CLAUDE_CACHE_OBSERVATION_SCHEMA_VERSION,
67
74
  FUSION_CLAUDE_CACHE_RETENTION_ENV,
75
+ FUSION_CLAUDE_PROMPT_CACHING_SCOPE_BETA,
76
+ applyFusionClaudePromptCachingScopeHeader,
68
77
  nonAnthropicFusionCacheObservation,
69
78
  normalizeFusionClaudeCachePayload,
70
79
  resolveFusionClaudeCachePolicy,
@@ -75,6 +84,7 @@ export {
75
84
  } from './core/fusion/claude-cache.js';
76
85
 
77
86
  export {
87
+ FUSION_CANDIDATE_OUTPUT_RECOVERY_PATH_ENV,
78
88
  FUSION_CHILD_MAX_PROVIDER_REQUESTS,
79
89
  FUSION_CHILD_MAX_TOOL_CALLS,
80
90
  FUSION_CHILD_MAX_TOTAL_TOOL_RESULT_BYTES,
@@ -103,6 +113,12 @@ export {
103
113
  type FusionRuntimeGuardRecord,
104
114
  } from './core/fusion/child-protocol.js';
105
115
 
116
+ export {
117
+ FUSION_CANDIDATE_MAX_OUTPUT_BYTES,
118
+ FUSION_CANDIDATE_OUTPUT_COMPRESSION_PROMPT,
119
+ fusionJsonRenderedTextBytes,
120
+ } from './core/fusion/output-contract.js';
121
+
106
122
  const FUSION_CHILD_O_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
107
123
 
108
124
  const FusionWebFetchParams = Type.Object(
@@ -253,6 +269,24 @@ function createToolCallLog(path: string): void {
253
269
  fsyncParentDirectorySync(path);
254
270
  }
255
271
 
272
+ function createCandidateOutputRecoveryArtifact(path: string, text: string): void {
273
+ if (!isAbsolute(path)) {
274
+ throw new Error(`${FUSION_CANDIDATE_OUTPUT_RECOVERY_PATH_ENV} must be an absolute path`);
275
+ }
276
+ const bytes = Buffer.from(text, 'utf8');
277
+ withRegularFileDescriptorSync(
278
+ path,
279
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | FUSION_CHILD_O_NOFOLLOW,
280
+ 0o600,
281
+ 'fusion oversized candidate response',
282
+ (fd) => {
283
+ writeAllSync(fd, bytes, 'fusion oversized candidate response');
284
+ fsyncSync(fd);
285
+ },
286
+ );
287
+ fsyncParentDirectorySync(path);
288
+ }
289
+
256
290
  function appendToolCallLogLine(path: string, record: FusionToolCallLogRecord): void {
257
291
  // The log is an audit trail, not a payload copy: raw tool arguments/results may
258
292
  // contain secrets, so only byte counts and SHA-256 digests are persisted.
@@ -670,6 +704,15 @@ function fusionWebFetchResultText(result: Awaited<ReturnType<typeof fusionWebFet
670
704
  */
671
705
  export default function fusionChildExtension(pi: ExtensionAPI): void {
672
706
  const toolCallLogPath = process.env[FUSION_TOOL_CALL_LOG_PATH_ENV];
707
+ const candidateOutputRecoveryPath = process.env[FUSION_CANDIDATE_OUTPUT_RECOVERY_PATH_ENV];
708
+ if (
709
+ candidateOutputRecoveryPath !== undefined &&
710
+ (candidateOutputRecoveryPath.trim().length === 0 || !isAbsolute(candidateOutputRecoveryPath))
711
+ ) {
712
+ throw new Error(
713
+ `${FUSION_CANDIDATE_OUTPUT_RECOVERY_PATH_ENV} must be an absolute non-blank path`,
714
+ );
715
+ }
673
716
  const researchEnabled = process.env[FUSION_RESEARCH_ENABLED_ENV];
674
717
  if (researchEnabled !== undefined && researchEnabled !== '1') {
675
718
  throw new Error(`${FUSION_RESEARCH_ENABLED_ENV} must be unset or exactly 1`);
@@ -684,10 +727,17 @@ export default function fusionChildExtension(pi: ExtensionAPI): void {
684
727
  let providerRequestCount = 0;
685
728
  let toolCallCount = 0;
686
729
  let runtimeGuardFailed = false;
730
+ let outputRecoveryFailed = false;
731
+ let outputRecoveryPhase: 'eligible' | 'queued' | 'finished' = 'eligible';
687
732
  let settlementPublished = false;
688
733
  const childResultRecords: FusionChildResultMetadata[] = [];
689
734
  let pendingCacheObservation: FusionClaudeCacheObservation | undefined;
690
735
 
736
+ pi.on('before_provider_headers', (event, ctx) => {
737
+ if (ctx.model?.provider !== 'anthropic') return;
738
+ applyFusionClaudePromptCachingScopeHeader(event.headers);
739
+ });
740
+
691
741
  pi.on('before_provider_request', async (event, ctx) => {
692
742
  providerRequestCount += 1;
693
743
  if (runtimeGuardFailed) {
@@ -820,6 +870,15 @@ export default function fusionChildExtension(pi: ExtensionAPI): void {
820
870
  pi.on('tool_call', async (event, ctx) => {
821
871
  try {
822
872
  requireOpen('tool_call');
873
+ if (outputRecoveryPhase === 'queued') {
874
+ outputRecoveryFailed = true;
875
+ latchAuditProcessFailure();
876
+ ctx.abort();
877
+ return {
878
+ block: true,
879
+ reason: 'fusion candidate output compression is a no-tool continuation',
880
+ };
881
+ }
823
882
  if (runtimeGuardFailed) {
824
883
  ctx.abort();
825
884
  return { block: true, reason: 'fusion child runtime guard already refused the run' };
@@ -978,13 +1037,68 @@ export default function fusionChildExtension(pi: ExtensionAPI): void {
978
1037
  if (event.message.role !== 'assistant') return;
979
1038
  const cacheObservation = pendingCacheObservation;
980
1039
  if (cacheObservation === undefined) {
1040
+ // Authentication and other pre-transport failures can produce an assistant
1041
+ // error without ever reaching before_provider_request. Preserve Pi's original
1042
+ // provider diagnostic and let settlement report no_records; emitting a second
1043
+ // cache-policy error would mask the actionable failure. A successful result
1044
+ // without an observation remains a hard invariant violation.
1045
+ if (event.message.stopReason === 'error') return;
981
1046
  latchAuditProcessFailure();
982
1047
  throw new Error('fusion child assistant result has no matching cache-policy observation');
983
1048
  }
984
1049
  pendingCacheObservation = undefined;
985
- const record = buildFusionChildResultMetadata(event.message, cacheObservation);
1050
+ const text = event.message.content
1051
+ .flatMap((part) => (part.type === 'text' ? [part.text] : []))
1052
+ .join('');
1053
+ const renderedBytes = fusionJsonRenderedTextBytes(text);
1054
+ const isReplacement = outputRecoveryPhase === 'queued';
1055
+ const shouldRecover =
1056
+ candidateOutputRecoveryPath !== undefined &&
1057
+ outputRecoveryPhase === 'eligible' &&
1058
+ event.message.stopReason === 'stop' &&
1059
+ renderedBytes > FUSION_CANDIDATE_MAX_OUTPUT_BYTES;
1060
+ const recoveryRole = isReplacement
1061
+ ? 'replacement'
1062
+ : shouldRecover
1063
+ ? 'oversized_original'
1064
+ : 'none';
1065
+ const record = buildFusionChildResultMetadata(event.message, cacheObservation, {
1066
+ candidateLimitBytes:
1067
+ candidateOutputRecoveryPath === undefined ? null : FUSION_CANDIDATE_MAX_OUTPUT_BYTES,
1068
+ recoveryRole,
1069
+ });
1070
+ if (shouldRecover) {
1071
+ try {
1072
+ createCandidateOutputRecoveryArtifact(candidateOutputRecoveryPath, text);
1073
+ } catch (error) {
1074
+ outputRecoveryFailed = true;
1075
+ latchAuditProcessFailure();
1076
+ throw error;
1077
+ }
1078
+ }
986
1079
  await writeMetadata(record);
987
1080
  childResultRecords.push(record);
1081
+ if (shouldRecover) {
1082
+ outputRecoveryPhase = 'queued';
1083
+ try {
1084
+ pi.setActiveTools([]);
1085
+ pi.sendUserMessage(FUSION_CANDIDATE_OUTPUT_COMPRESSION_PROMPT, {
1086
+ deliverAs: 'followUp',
1087
+ });
1088
+ } catch (error) {
1089
+ outputRecoveryFailed = true;
1090
+ latchAuditProcessFailure();
1091
+ throw error;
1092
+ }
1093
+ return;
1094
+ }
1095
+ if (isReplacement) {
1096
+ outputRecoveryPhase = 'finished';
1097
+ if (renderedBytes > FUSION_CANDIDATE_MAX_OUTPUT_BYTES) {
1098
+ outputRecoveryFailed = true;
1099
+ latchAuditProcessFailure();
1100
+ }
1101
+ }
988
1102
  });
989
1103
  pi.on('agent_settled', async (_event, ctx) => {
990
1104
  if (settlementPublished) {
@@ -1001,6 +1115,7 @@ export default function fusionChildExtension(pi: ExtensionAPI): void {
1001
1115
  childResultRecords,
1002
1116
  runtimeGuardFailed,
1003
1117
  cacheObservationFailed,
1118
+ outputRecoveryFailed,
1004
1119
  );
1005
1120
  if (settlement.status !== 'complete') latchAuditProcessFailure();
1006
1121
  await writeSettlement(settlement);