pi-usereq 0.80.0 → 0.81.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 CHANGED
@@ -1,4 +1,4 @@
1
- # PI-useReq/pi-usereq (0.80.0)
1
+ # PI-useReq/pi-usereq (0.81.0)
2
2
 
3
3
  <p align="center">
4
4
  <img src="https://img.shields.io/badge/python-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.11+">
@@ -335,6 +335,26 @@ The extension renders a status field in the pi status line showing: extension id
335
335
  pi -p "Rewrite this function" --append-system-prompt /tmp/PI-useReq/20261009072235123-req-implement
336
336
  ```
337
337
 
338
+ - **Provider error logging** — when a dispatched `req-*` run receives a provider response with status >= 400, pi-usereq writes the exact failing request payload into `<timestamp>-<command>-error-<status>` (raw provider-request JSON), the provider error message text into `<timestamp>-<command>-error-<status>-response`, and replay metadata into `<timestamp>-<command>-error-<status>-replay.json`, all beside the run's `-request` and `-prompt` files. The `-replay.json` sidecar carries the provider, base URL, model id, thinking level, context window, max output tokens, the retained execution-session file, and ready-to-use replay commands. You can re-generate the failure for immediate pi CLI debugging:
339
+
340
+ ```bash
341
+ # Re-send the exact failing provider request (the payload file is raw request JSON)
342
+ curl -sS "$(jq -r '.replayCommands.provider' /tmp/PI-useReq/20261012070707447-req-analyze-error-400-replay.json)"
343
+
344
+ # Or replay it directly against the captured body
345
+ curl -sS https://openrouter.ai/api/v1/chat/completions \
346
+ -H "Authorization: Bearer $OPENROUTER_API_KEY" \
347
+ -H "Content-Type: application/json" \
348
+ --data @/tmp/PI-useReq/20261012070707447-req-analyze-error-400
349
+
350
+ # Replay the saved prompt through pi with the same model and thinking level
351
+ pi --no-session --model '~deepseek/deepseek-flash-latest' --thinking high \
352
+ -p "$(cat /tmp/PI-useReq/20261012070647580-req-analyze-prompt)"
353
+
354
+ # Fork the retained execution session (kept on failed runs) and continue it
355
+ pi --fork /home/ogekuri/.pi/agent/sessions/--home-ogekuri-Git-PI-useReq--/2026-10-12T05-06-46-788Z_01a12e81-9584-72f4-92e3-19008d04cccf.jsonl
356
+ ```
357
+
338
358
 
339
359
  ## Note on Git usage
340
360
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-usereq",
3
- "version": "0.80.0",
3
+ "version": "0.81.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/Ogekuri/PI-useReq.git"
@@ -831,45 +831,86 @@ export function logDebugPromptContent(
831
831
  promptName: PromptCommandName,
832
832
  content: string,
833
833
  ): boolean {
834
- return writePromptDebugFile(
834
+ const fileName = formatPromptDebugPromptFileName(promptName);
835
+ const written = writePromptDebugFile(
835
836
  projectBase,
836
837
  config,
837
838
  workflowState,
838
839
  promptName,
839
- formatPromptDebugPromptFileName(promptName),
840
+ fileName,
840
841
  content,
841
842
  );
843
+ if (written) {
844
+ getProcessScopedPromptErrorCaptureStore().dispatchedPromptDebugFiles.set(promptName, fileName);
845
+ }
846
+ return written;
847
+ }
848
+
849
+ /**
850
+ * @brief Describes replay facts captured beside one provider request payload for reproduction.
851
+ * @details Stores the provider, API, base URL, model identifier, thinking level, context-window, max-output-token, and execution-session-file facts resolved from the live pi runtime model plus prompt execution plan so the error-stage replay sidecar can emit ready-to-use replay command lines without live runtime state. Every field stays optional so degraded runtimes and offline replays never abort the capture. The interface is compile-time only and introduces no runtime cost.
852
+ * @satisfies REQ-430
853
+ */
854
+ export interface PromptErrorReplayFacts {
855
+ modelId: string | undefined;
856
+ provider: string | undefined;
857
+ api: string | undefined;
858
+ baseUrl: string | undefined;
859
+ thinkingLevel: string | undefined;
860
+ contextWindow: number | undefined;
861
+ maxTokens: number | undefined;
862
+ executionSessionFile: string | undefined;
842
863
  }
843
864
 
844
865
  /**
845
866
  * @brief Describes one process-scoped captured provider request payload bound to an active prompt run.
846
- * @details Pairs the bundled prompt name, the serialization of the exact `before_provider_request` payload, the original project base, and the resolved prompt log directory so a later `after_provider_response` error status can dump the failing request verbatim without live controller state, cached configuration, or live provider objects. The interface is compile-time only and introduces no runtime cost.
847
- * @satisfies REQ-421
867
+ * @details Pairs the bundled prompt name, the serialization of the exact `before_provider_request` payload, the original project base, the resolved prompt log directory, the dispatched prompt debug filename of the same run, and the captured replay facts so a later `after_provider_response` or `message_end` error status can dump the failing request verbatim, its provider error text, and one replay sidecar without live controller state, cached configuration, or live provider objects. The interface is compile-time only and introduces no runtime cost.
868
+ * @satisfies REQ-421, REQ-429, REQ-430
848
869
  */
849
870
  interface CapturedPromptErrorPayload {
850
871
  promptName: PromptCommandName;
851
872
  payloadText: string;
852
873
  projectBase: string;
853
874
  logDirectory: string;
875
+ promptDebugFile: string | undefined;
876
+ replayFacts: PromptErrorReplayFacts;
877
+ }
878
+
879
+ /**
880
+ * @brief Describes the location of one already flushed provider error payload file set.
881
+ * @details Stores the prompt name, failing status, resolved log directory, and payload base filename of the most recent error-stage flush so a later finalized `message_end` error message can still append the `-response` file beside the already written payload when the provider fired `after_provider_response` before surfacing its error text. The interface is compile-time only and introduces no runtime cost.
882
+ * @satisfies REQ-429
883
+ */
884
+ interface FlushedPromptErrorRecord {
885
+ promptName: PromptCommandName;
886
+ errorCode: number;
887
+ logDirectory: string;
888
+ baseFileName: string;
854
889
  }
855
890
 
856
891
  /**
857
892
  * @brief Describes the process-scoped provider error payload capture state persisted across extension rebinds.
858
- * @details Stores the latest captured prompt error payload on `globalThis` because pi rebinds extension modules for `/new`, `/resume`, `/fork`, and `/reload` between prompt dispatch and provider execution, while the hosting process persists across those operations, so the capture survives the forked execution-session switch. The interface is compile-time only and introduces no runtime cost.
893
+ * @details Stores the latest captured prompt error payload, the latest flushed error record, and the latest dispatched prompt debug filenames on `globalThis` because pi rebinds extension modules for `/new`, `/resume`, `/fork`, and `/reload` between prompt dispatch and provider execution, while the hosting process persists across those operations, so the capture, the late response write, and the dispatched-prompt correlation survive the forked execution-session switch. The interface is compile-time only and introduces no runtime cost.
859
894
  */
860
895
  interface ProcessScopedPromptErrorCaptureStore {
861
896
  capturedPromptErrorPayload: CapturedPromptErrorPayload | undefined;
897
+ lastFlushedPromptError: FlushedPromptErrorRecord | undefined;
898
+ dispatchedPromptDebugFiles: Map<PromptCommandName, string>;
862
899
  }
863
900
 
864
901
  /**
865
902
  * @brief Returns the process-scoped provider error payload capture store.
866
- * @details Lazily initializes one `globalThis` record so session rebinds never discard an already captured failing provider request. Runtime is O(1). Side effect: initializes process-scoped state on first access.
903
+ * @details Lazily initializes one `globalThis` record so session rebinds never discard an already captured failing provider request, its flushed error record, or its dispatched prompt debug filename correlation. Runtime is O(1). Side effect: initializes process-scoped state on first access.
867
904
  * @return {ProcessScopedPromptErrorCaptureStore} Mutable process-scoped capture store.
868
905
  */
869
906
  function getProcessScopedPromptErrorCaptureStore(): ProcessScopedPromptErrorCaptureStore {
870
907
  const globalScope = globalThis as typeof globalThis & { __piUsereqPromptErrorCaptureStore?: ProcessScopedPromptErrorCaptureStore };
871
908
  if (!globalScope.__piUsereqPromptErrorCaptureStore) {
872
- globalScope.__piUsereqPromptErrorCaptureStore = { capturedPromptErrorPayload: undefined };
909
+ globalScope.__piUsereqPromptErrorCaptureStore = {
910
+ capturedPromptErrorPayload: undefined,
911
+ lastFlushedPromptError: undefined,
912
+ dispatchedPromptDebugFiles: new Map(),
913
+ };
873
914
  }
874
915
  return globalScope.__piUsereqPromptErrorCaptureStore;
875
916
  }
@@ -891,6 +932,40 @@ export function formatPromptDebugErrorFileName(
891
932
  return `${formatPromptDebugTimestamp(date)}-${getDebugPromptName(promptName)}-error-${errorCode}`;
892
933
  }
893
934
 
935
+ /**
936
+ * @brief Builds one provider error response debug filename for one bundled prompt.
937
+ * @details Appends the `-response` suffix to the shared `<timestamp>-<req-command>-error-<status>` shape so the persisted provider error text stays attributable to the exact failing request payload file written with the identical write-time timestamp. Runtime is O(1). No external state is mutated.
938
+ * @param[in] promptName {PromptCommandName} Bundled prompt name.
939
+ * @param[in] errorCode {number} Failing provider response status, such as `400`.
940
+ * @param[in] date {Date} Write-time timestamp shared with the payload file. Defaults to the current wall-clock time.
941
+ * @return {string} Provider error response filename in the `<timestamp>-<req-command>-error-<status>-response` shape.
942
+ * @satisfies REQ-423, REQ-429
943
+ */
944
+ export function formatPromptDebugErrorResponseFileName(
945
+ promptName: PromptCommandName,
946
+ errorCode: number,
947
+ date = new Date(),
948
+ ): string {
949
+ return `${formatPromptDebugErrorFileName(promptName, errorCode, date)}-response`;
950
+ }
951
+
952
+ /**
953
+ * @brief Builds one provider error replay sidecar debug filename for one bundled prompt.
954
+ * @details Appends the `-replay.json` suffix to the shared `<timestamp>-<req-command>-error-<status>` shape so the persisted replay metadata sidecar stays attributable to the exact failing request payload file written with the identical write-time timestamp. Runtime is O(1). No external state is mutated.
955
+ * @param[in] promptName {PromptCommandName} Bundled prompt name.
956
+ * @param[in] errorCode {number} Failing provider response status, such as `400`.
957
+ * @param[in] date {Date} Write-time timestamp shared with the payload file. Defaults to the current wall-clock time.
958
+ * @return {string} Provider error replay sidecar filename in the `<timestamp>-<req-command>-error-<status>-replay.json` shape.
959
+ * @satisfies REQ-423, REQ-430
960
+ */
961
+ export function formatPromptDebugErrorReplayFileName(
962
+ promptName: PromptCommandName,
963
+ errorCode: number,
964
+ date = new Date(),
965
+ ): string {
966
+ return `${formatPromptDebugErrorFileName(promptName, errorCode, date)}-replay.json`;
967
+ }
968
+
894
969
  /**
895
970
  * @brief Resolves the failing HTTP status carried by one finalized agent message.
896
971
  * @details Extracts the leading provider error status from one `message_end` assistant message whose `stopReason` equals `error`, accepting the pi `formatProviderError` display shapes (`"<status>: <body>"` and `"<prefix> (<status>): <body>"`) plus the raw SDK `error.message` shape (`"<status> <body>"`) surfaced unchanged by the agent run-failure path, so provider APIs whose SDK clients throw before emitting `after_provider_response` for non-2xx statuses (the OpenAI-compatible family, including `zai`) still flush the captured provider request payload through the message lifecycle channel. Runtime is O(n) in the inspected error-message prefix length. No external state is mutated.
@@ -946,54 +1021,230 @@ function serializePromptProviderPayload(payload: unknown): string {
946
1021
  ) ?? "";
947
1022
  }
948
1023
 
1024
+ /**
1025
+ * @brief Resolves the model identifier embedded in one provider request payload.
1026
+ * @details Reads the string `model` field of the serialized request payload so the replay sidecar can still name the failing model when the live runtime model object was unavailable at capture time. Runtime is O(1). No external state is mutated.
1027
+ * @param[in] payload {unknown} Exact `before_provider_request` provider request payload.
1028
+ * @return {string | undefined} Payload model identifier when present as a string; otherwise `undefined`.
1029
+ */
1030
+ function resolveProviderPayloadModelId(payload: unknown): string | undefined {
1031
+ if (typeof payload !== "object" || payload === null) {
1032
+ return undefined;
1033
+ }
1034
+ const modelId = (payload as { model?: unknown }).model;
1035
+ return typeof modelId === "string" && modelId.length > 0 ? modelId : undefined;
1036
+ }
1037
+
949
1038
  /**
950
1039
  * @brief Captures one exact provider request payload for the active prompt run when prompt debug logging is enabled.
951
- * @details Applies the global-debug plus prompt-debug gating, serializes the exact `before_provider_request` payload through the cycle-safe serializer, and stores the prompt name, payload text, original project base, and resolved prompt log directory in the process-scoped capture store so the payload survives session replacement and later error-stage writes require no live workflow state or cached configuration. Runtime is O(n) in serialized payload size when enabled and O(1) otherwise. Side effect: mutates the process-scoped capture store for enabled matching requests.
1040
+ * @details Applies the global-debug plus prompt-debug gating, serializes the exact `before_provider_request` payload through the cycle-safe serializer, resolves the dispatched prompt debug filename of the same run from the process-scoped correlation map, and stores the prompt name, payload text, original project base, resolved prompt log directory, replay facts, and prompt debug filename in the process-scoped capture store so the payload, its error text, and its replay sidecar survive session replacement and later error-stage writes require no live workflow state or cached configuration. Falls back to the payload `model` field when the supplied replay facts omit the model identifier. Runtime is O(n) in serialized payload size when enabled and O(1) otherwise. Side effect: mutates the process-scoped capture store for enabled matching requests.
952
1041
  * @param[in] config {UseReqConfig} Effective project configuration.
953
1042
  * @param[in] promptName {PromptCommandName} Active bundled prompt name.
954
1043
  * @param[in] projectBase {string} Absolute original project base path used to resolve the prompt log directory.
955
1044
  * @param[in] payload {unknown} Exact `before_provider_request` provider request payload.
1045
+ * @param[in] replayFacts {PromptErrorReplayFacts} Live runtime replay facts consumed by the error-stage sidecar. Defaults to empty facts.
956
1046
  * @return {void} No return value.
957
- * @satisfies REQ-416, REQ-421
1047
+ * @satisfies REQ-416, REQ-421, REQ-430
958
1048
  */
959
1049
  export function capturePromptProviderRequestForDebug(
960
1050
  config: UseReqConfig,
961
1051
  promptName: PromptCommandName,
962
1052
  projectBase: string,
963
1053
  payload: unknown,
1054
+ replayFacts: PromptErrorReplayFacts = {
1055
+ modelId: undefined,
1056
+ provider: undefined,
1057
+ api: undefined,
1058
+ baseUrl: undefined,
1059
+ thinkingLevel: undefined,
1060
+ contextWindow: undefined,
1061
+ maxTokens: undefined,
1062
+ executionSessionFile: undefined,
1063
+ },
964
1064
  ): void {
965
1065
  if (!shouldLogDebugPromptContent(config, "unknown", promptName)) {
966
1066
  return;
967
1067
  }
1068
+ const logDirectory = resolveDebugPromptsLogPath(projectBase, config);
1069
+ const dispatchedPromptFile = getProcessScopedPromptErrorCaptureStore().dispatchedPromptDebugFiles.get(promptName);
968
1070
  getProcessScopedPromptErrorCaptureStore().capturedPromptErrorPayload = {
969
1071
  promptName,
970
1072
  payloadText: serializePromptProviderPayload(payload),
971
1073
  projectBase,
972
- logDirectory: resolveDebugPromptsLogPath(projectBase, config),
1074
+ logDirectory,
1075
+ promptDebugFile: dispatchedPromptFile === undefined ? undefined : path.join(logDirectory, dispatchedPromptFile),
1076
+ replayFacts: {
1077
+ ...replayFacts,
1078
+ modelId: replayFacts.modelId ?? resolveProviderPayloadModelId(payload),
1079
+ },
973
1080
  };
974
1081
  }
975
1082
 
976
1083
  /**
977
- * @brief Writes the captured provider request payload as one error-stage prompt debug file for the failing prompt.
978
- * @details Consumes the process-scoped capture, discards it after every write attempt so stale payloads never reach later runs, rejects failing statuses below 400, and writes the exact captured payload into one `<timestamp>-<req-command>-error-<status>` file inside the captured log directory beside the initial dispatched prompt artifacts. All write inputs come from the capture itself, so the flush requires no live workflow state, cached configuration, or active controller prompt request. Runtime is dominated by one directory creation plus one file write when a capture exists and O(1) otherwise. Side effects include directory creation and file creation only when a capture exists and the status is an error status.
1084
+ * @brief Normalizes one provider name into an uppercase environment-variable token.
1085
+ * @details Uppercases the provider name and replaces every non-alphanumeric character with `_` so the generated replay command can reference a deterministic `<PROVIDER>_API_KEY` environment variable. Runtime is O(n) in provider-name length. No external state is mutated.
1086
+ * @param[in] provider {string} Provider identifier, such as `openrouter`.
1087
+ * @return {string} Uppercase alphanumeric environment-variable token.
1088
+ */
1089
+ function normalizeProviderEnvToken(provider: string): string {
1090
+ const normalized = provider.toUpperCase().replace(/[^A-Z0-9]/gu, "_");
1091
+ return normalized.length > 0 ? normalized : "PROVIDER";
1092
+ }
1093
+
1094
+ /**
1095
+ * @brief Resolves the provider HTTP endpoint targeted by one captured provider request payload.
1096
+ * @details Appends the canonical chat-completions path for `openai-completions` APIs and the canonical messages path for `anthropic-messages` APIs, and preserves the raw base URL for every other API so the generated provider replay command never fabricates an endpoint it cannot derive. Runtime is O(p) in URL length. No external state is mutated.
1097
+ * @param[in] facts {PromptErrorReplayFacts} Captured replay facts supplying the API and base URL.
1098
+ * @return {string | undefined} Replay endpoint URL when a base URL exists; otherwise `undefined`.
1099
+ */
1100
+ function resolveProviderReplayEndpoint(facts: PromptErrorReplayFacts): string | undefined {
1101
+ if (facts.baseUrl === undefined) {
1102
+ return undefined;
1103
+ }
1104
+ if (facts.api === "openai-completions") {
1105
+ return `${facts.baseUrl}/chat/completions`;
1106
+ }
1107
+ if (facts.api === "anthropic-messages") {
1108
+ return `${facts.baseUrl}/v1/messages`;
1109
+ }
1110
+ return facts.baseUrl;
1111
+ }
1112
+
1113
+ /**
1114
+ * @brief Builds the replay sidecar JSON text for one flushed provider error payload.
1115
+ * @details Serializes the captured provider, base URL, API, model identifier, thinking level, context-window and max-output-token facts, the execution-session file, the dispatched prompt debug file, the flushed request payload file, and ready-to-use replay command lines: one `curl --data @` provider replay of the exact failing request, one pi one-shot replay of the dispatched prompt file, and one `pi --fork` replay of the retained execution session. Command lines are emitted only when their supporting facts exist so every emitted command is directly executable. Runtime is O(n) in serialized sidecar size. No external state is mutated.
1116
+ * @param[in] captured {CapturedPromptErrorPayload} Consumed capture supplying replay facts and artifact paths.
1117
+ * @param[in] requestFileName {string} Payload filename written with the same write-time timestamp.
1118
+ * @return {string} Newline-terminated replay sidecar JSON text.
1119
+ * @satisfies REQ-430
1120
+ */
1121
+ function buildPromptErrorReplaySidecarText(
1122
+ captured: CapturedPromptErrorPayload,
1123
+ requestFileName: string,
1124
+ ): string {
1125
+ const facts = captured.replayFacts;
1126
+ const requestFilePath = path.join(captured.logDirectory, requestFileName);
1127
+ const endpoint = resolveProviderReplayEndpoint(facts);
1128
+ const replayCommands: Record<string, string> = {};
1129
+ if (endpoint !== undefined) {
1130
+ const envToken = facts.provider === undefined ? "PROVIDER_API_KEY" : `${normalizeProviderEnvToken(facts.provider)}_API_KEY`;
1131
+ replayCommands.provider = `curl -sS '${endpoint}' -H 'Authorization: Bearer $${envToken}' -H 'Content-Type: application/json' --data @'${requestFilePath}'`;
1132
+ }
1133
+ if (captured.promptDebugFile !== undefined && facts.modelId !== undefined) {
1134
+ replayCommands.piOneShot = `pi --no-session --model '${facts.modelId}' --thinking ${facts.thinkingLevel ?? "off"} -p "$(cat '${captured.promptDebugFile}')"`;
1135
+ }
1136
+ if (facts.executionSessionFile !== undefined) {
1137
+ const modelFlags = facts.modelId === undefined ? "" : ` --model '${facts.modelId}'`;
1138
+ const thinkingFlags = facts.thinkingLevel === undefined ? "" : ` --thinking ${facts.thinkingLevel}`;
1139
+ replayCommands.piFork = `pi --fork '${facts.executionSessionFile}'${modelFlags}${thinkingFlags}`;
1140
+ }
1141
+ const sidecar = {
1142
+ provider: facts.provider ?? null,
1143
+ baseUrl: facts.baseUrl ?? null,
1144
+ api: facts.api ?? null,
1145
+ modelId: facts.modelId ?? null,
1146
+ thinkingLevel: facts.thinkingLevel ?? null,
1147
+ contextWindow: facts.contextWindow ?? null,
1148
+ maxTokens: facts.maxTokens ?? null,
1149
+ sessionFile: facts.executionSessionFile ?? null,
1150
+ promptFile: captured.promptDebugFile ?? null,
1151
+ requestFile: requestFilePath,
1152
+ replayCommands,
1153
+ };
1154
+ return `${JSON.stringify(sidecar, null, 2)}\n`;
1155
+ }
1156
+
1157
+ /**
1158
+ * @brief Writes the captured provider request payload plus its response text and replay sidecar as error-stage prompt debug files.
1159
+ * @details Consumes the process-scoped capture, discards it after every write attempt so stale payloads never reach later runs, rejects failing statuses below 400, and writes the exact captured payload into one `<timestamp>-<req-command>-error-<status>` file that stays raw provider-request JSON usable with `curl --data @`, the provider error text into one identically timestamped `-response` file when supplied, and the replay sidecar into one identically timestamped `-replay.json` file inside the captured log directory beside the initial dispatched prompt artifacts. Records the flushed location so a later finalized `message_end` error message can still append the `-response` file when `after_provider_response` fired first without error text. All write inputs come from the capture itself, so the flush requires no live workflow state, cached configuration, or active controller prompt request. Runtime is dominated by one directory creation plus at most three file writes when a capture exists and O(1) otherwise. Side effects include directory creation and file creation only when a capture exists and the status is an error status.
979
1160
  * @param[in] errorCode {number} Failing provider response status, such as `400`.
980
- * @return {boolean} `true` when the provider error payload file is written; otherwise `false`.
981
- * @satisfies REQ-422, REQ-423, REQ-424, REQ-425
1161
+ * @param[in] errorResponseText {string | undefined} Finalized provider error message text when already available; the `-response` file is written later when omitted.
1162
+ * @return {boolean} `true` when the provider error payload file set is written; otherwise `false`.
1163
+ * @satisfies REQ-422, REQ-423, REQ-424, REQ-425, REQ-429, REQ-430, REQ-431
982
1164
  */
983
- export function flushCapturedPromptErrorPayload(errorCode: number): boolean {
1165
+ export function flushCapturedPromptErrorPayload(errorCode: number, errorResponseText?: string): boolean {
984
1166
  const captureStore = getProcessScopedPromptErrorCaptureStore();
985
1167
  const captured = captureStore.capturedPromptErrorPayload;
986
1168
  captureStore.capturedPromptErrorPayload = undefined;
987
1169
  if (!captured || !Number.isFinite(errorCode) || errorCode < 400) {
988
1170
  return false;
989
1171
  }
1172
+ const writeTime = new Date();
1173
+ const payloadFileName = formatPromptDebugErrorFileName(captured.promptName, errorCode, writeTime);
990
1174
  try {
991
1175
  fs.mkdirSync(captured.logDirectory, { recursive: true });
992
1176
  fs.writeFileSync(
993
- path.join(captured.logDirectory, formatPromptDebugErrorFileName(captured.promptName, errorCode)),
1177
+ path.join(captured.logDirectory, payloadFileName),
994
1178
  captured.payloadText,
995
1179
  "utf8",
996
1180
  );
1181
+ if (typeof errorResponseText === "string" && errorResponseText.length > 0) {
1182
+ writePromptProviderErrorResponseFile(
1183
+ captured.logDirectory,
1184
+ formatPromptDebugErrorResponseFileName(captured.promptName, errorCode, writeTime),
1185
+ errorResponseText,
1186
+ );
1187
+ }
1188
+ fs.writeFileSync(
1189
+ path.join(captured.logDirectory, formatPromptDebugErrorReplayFileName(captured.promptName, errorCode, writeTime)),
1190
+ buildPromptErrorReplaySidecarText(captured, payloadFileName),
1191
+ "utf8",
1192
+ );
1193
+ captureStore.lastFlushedPromptError = {
1194
+ promptName: captured.promptName,
1195
+ errorCode,
1196
+ logDirectory: captured.logDirectory,
1197
+ baseFileName: payloadFileName,
1198
+ };
1199
+ return true;
1200
+ } catch {
1201
+ return false;
1202
+ }
1203
+ }
1204
+
1205
+ /**
1206
+ * @brief Writes one provider error response text file under a resolved log directory.
1207
+ * @details Creates the parent directory and writes the finalized provider error text with one trailing newline into the requested response filename so both the flush-time and the late `message_end` response writes share one deterministic serialization. Runtime is dominated by one directory creation plus one file write. Side effects include directory creation and file creation.
1208
+ * @param[in] logDirectory {string} Absolute prompt log directory owning the response file.
1209
+ * @param[in] fileName {string} Target response filename under the log directory.
1210
+ * @param[in] errorResponseText {string} Finalized provider error message text.
1211
+ * @return {void} No return value.
1212
+ * @satisfies REQ-429
1213
+ */
1214
+ function writePromptProviderErrorResponseFile(
1215
+ logDirectory: string,
1216
+ fileName: string,
1217
+ errorResponseText: string,
1218
+ ): void {
1219
+ fs.mkdirSync(logDirectory, { recursive: true });
1220
+ fs.writeFileSync(path.join(logDirectory, fileName), `${errorResponseText}\n`, "utf8");
1221
+ }
1222
+
1223
+ /**
1224
+ * @brief Appends the provider error response text beside one already flushed error payload file.
1225
+ * @details Providers that fire `after_provider_response` before surfacing their error text flush the payload without a `-response` file, so this helper writes the finalized `message_end` error message beside the recorded flush location when the failing status matches. Runtime is dominated by one directory creation plus one file write when a matching record exists and O(1) otherwise. Side effects include directory creation and file creation only for a matching record.
1226
+ * @param[in] errorCode {number} Failing provider response status carried by the finalized error message.
1227
+ * @param[in] errorResponseText {string} Finalized provider error message text.
1228
+ * @return {boolean} `true` when the response file is written; otherwise `false`.
1229
+ * @satisfies REQ-429
1230
+ */
1231
+ export function writeFlushedPromptProviderErrorResponse(
1232
+ errorCode: number,
1233
+ errorResponseText: string,
1234
+ ): boolean {
1235
+ if (typeof errorResponseText !== "string" || errorResponseText.length === 0) {
1236
+ return false;
1237
+ }
1238
+ const record = getProcessScopedPromptErrorCaptureStore().lastFlushedPromptError;
1239
+ if (record === undefined || record.errorCode !== errorCode) {
1240
+ return false;
1241
+ }
1242
+ try {
1243
+ writePromptProviderErrorResponseFile(
1244
+ record.logDirectory,
1245
+ `${record.baseFileName}-response`,
1246
+ errorResponseText,
1247
+ );
997
1248
  return true;
998
1249
  } catch {
999
1250
  return false;
@@ -1001,11 +1252,13 @@ export function flushCapturedPromptErrorPayload(errorCode: number): boolean {
1001
1252
  }
1002
1253
 
1003
1254
  /**
1004
- * @brief Discards any captured provider request payload for the ended prompt run.
1005
- * @details Clears the process-scoped capture unconditionally so leftover payloads from a finished `req-*` prompt run never flush into later provider errors raised outside extension-owned prompt orchestration. Runtime is O(1). Side effect: mutates the process-scoped capture store.
1255
+ * @brief Discards any captured provider request payload and flushed error record for the ended prompt run.
1256
+ * @details Clears the process-scoped capture and the latest flushed error record unconditionally so leftover payloads and flush locations from a finished `req-*` prompt run never flush into later provider errors raised outside extension-owned prompt orchestration. Runtime is O(1). Side effect: mutates the process-scoped capture store.
1006
1257
  * @return {void} No return value.
1007
- * @satisfies REQ-421, REQ-425
1258
+ * @satisfies REQ-421, REQ-425, REQ-429
1008
1259
  */
1009
1260
  export function discardCapturedPromptErrorPayload(): void {
1010
- getProcessScopedPromptErrorCaptureStore().capturedPromptErrorPayload = undefined;
1261
+ const captureStore = getProcessScopedPromptErrorCaptureStore();
1262
+ captureStore.capturedPromptErrorPayload = undefined;
1263
+ captureStore.lastFlushedPromptError = undefined;
1011
1264
  }
package/src/index.ts CHANGED
@@ -153,8 +153,10 @@ import {
153
153
  resolveProviderErrorFlushStatusFromMessage,
154
154
  setRuntimeDebugEnabled,
155
155
  shouldLogDebugPromptWorkflowState,
156
+ writeFlushedPromptProviderErrorResponse,
156
157
  type DebugLogOnStatus,
157
158
  type DebugWorkflowState,
159
+ type PromptErrorReplayFacts,
158
160
  } from "./core/debug-runtime.js";
159
161
  import { PROMPT_COMMAND_NAMES } from "./core/prompt-command-catalog.js";
160
162
  import { resolveRuntimeGitPath } from "./core/runtime-project-paths.js";
@@ -305,6 +307,31 @@ function loadPromptRunBaseConfig(projectBase: string): UseReqConfig {
305
307
  return normalizeConfigPaths(projectBase, loadConfig(projectBase));
306
308
  }
307
309
 
310
+ /**
311
+ * @brief Builds the replay facts captured beside one provider request payload.
312
+ * @details Reads the provider, API, base URL, model identifier, thinking level, context-window, and max-output-token facts from the live extension-context model plus the execution-session file from the live prompt execution plan so the error-stage replay sidecar can emit ready-to-use `curl` and `pi` replay command lines without live runtime state at flush time. Degrades every fact to `undefined` when the runtime model is unavailable so the capture never aborts. Runtime is O(1). No external state is mutated.
313
+ * @param[in] ctx {ExtensionContext} Active extension context supplying the live model and thinking level.
314
+ * @param[in] executionSessionFile {string | undefined} Persisted execution-session file of the active prompt run retained per REQ-209.
315
+ * @return {PromptErrorReplayFacts} Replay facts consumed by the error-stage sidecar writer.
316
+ * @satisfies REQ-430
317
+ */
318
+ function buildPromptProviderReplayFacts(
319
+ ctx: ExtensionContext,
320
+ executionSessionFile: string | undefined,
321
+ ): PromptErrorReplayFacts {
322
+ const model = ctx.model;
323
+ return {
324
+ modelId: model?.id,
325
+ provider: model?.provider,
326
+ api: model?.api,
327
+ baseUrl: model?.baseUrl,
328
+ thinkingLevel: ctx.thinkingLevel,
329
+ contextWindow: model?.contextWindow,
330
+ maxTokens: model?.maxTokens,
331
+ executionSessionFile,
332
+ };
333
+ }
334
+
308
335
  /**
309
336
  * @brief Loads project configuration for the extension runtime.
310
337
  * @details Resolves the project base, loads persisted config, and normalizes configured directory paths without reading or persisting runtime-derived `base-path` or `git-path` metadata. Runtime is dominated by config I/O. Side effects are limited to filesystem reads.
@@ -1545,11 +1572,15 @@ async function handleExtensionStatusEvent(
1545
1572
  livePromptRequest.promptName,
1546
1573
  livePromptRequest.basePath,
1547
1574
  providerRequestEvent.payload,
1575
+ buildPromptProviderReplayFacts(ctx, livePromptRequest.executionSessionFile),
1548
1576
  );
1549
1577
  }
1550
1578
  if (hookName === "after_provider_response") {
1551
1579
  const providerResponseEvent = event as { status?: unknown };
1552
1580
  if (typeof providerResponseEvent.status === "number" && providerResponseEvent.status >= 400) {
1581
+ // The response event carries status plus headers only, so the provider error text is
1582
+ // appended later by the finalized `message_end` error message through the recorded
1583
+ // flush location.
1553
1584
  flushCapturedPromptErrorPayload(providerResponseEvent.status);
1554
1585
  }
1555
1586
  }
@@ -1560,11 +1591,17 @@ async function handleExtensionStatusEvent(
1560
1591
  // `message_end` for every failed provider request with the HTTP status embedded in
1561
1592
  // `errorMessage` by `formatProviderError`.
1562
1593
  if (hookName === "message_end") {
1563
- const providerErrorStatus = resolveProviderErrorFlushStatusFromMessage(
1564
- (event as { message?: unknown }).message,
1565
- );
1594
+ const finalizedMessage = (event as { message?: unknown }).message as { errorMessage?: unknown } | undefined;
1595
+ const providerErrorStatus = resolveProviderErrorFlushStatusFromMessage(finalizedMessage);
1566
1596
  if (providerErrorStatus !== undefined) {
1567
- flushCapturedPromptErrorPayload(providerErrorStatus);
1597
+ const providerErrorText = typeof finalizedMessage?.errorMessage === "string"
1598
+ ? finalizedMessage.errorMessage
1599
+ : undefined;
1600
+ if (!flushCapturedPromptErrorPayload(providerErrorStatus, providerErrorText)) {
1601
+ // The capture was already flushed by `after_provider_response`, so the finalized error
1602
+ // text is appended beside the recorded payload location.
1603
+ writeFlushedPromptProviderErrorResponse(providerErrorStatus, providerErrorText ?? "");
1604
+ }
1568
1605
  }
1569
1606
  }
1570
1607
  if (hookName === "agent_end" || hookName === "agent_settled") {