pi-tool-duration 0.2.0 → 0.2.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.2] - 2026-09-18
4
+
5
+ ### Fixed
6
+
7
+ - Keep duration markers out of terminal tool output after reload, resume, and branch navigation by storing timing in hidden session entries and annotating only model-request copies.
8
+ - Preserve model-visible timings across restoration, compaction summaries, and retained compaction history without changing tool content or details.
9
+
10
+ ### Changed
11
+
12
+ - Verify recorded output, reload, restored timing, branch isolation, and repeated model requests in regression tests.
13
+ - Run clean-install checks on Node 22.19 and Node 24 in GitHub Actions.
14
+
15
+ ## [0.2.1] - 2026-08-09
16
+
17
+ ### Fixed
18
+
19
+ - Use Pi's error status instead of guessing from arbitrary result text or metadata, eliminating false duration markers on successful tools.
20
+ - Append duration markers to finalized model-visible messages so the TUI keeps its single native timer, missing-content tools stay safe, blocked preflight failures are covered, and marker-like tool output is still measured.
21
+ - Keep the long threshold flag readable in `pi --help` and declare the documented Pi 0.84.0 host floor in package metadata.
22
+ - Keep the integration-test dependency lockfile installable from the public npm registry.
23
+
24
+ ### Changed
25
+
26
+ - Added real Pi CLI integration coverage over a local HTTP model transport for timing, flags, failures, parallel calls, lifecycle cleanup, and result preservation.
27
+
3
28
  ## [0.2.0] - 2026-08-06
4
29
 
5
30
  ### Changed
package/README.md CHANGED
@@ -13,13 +13,19 @@ Pi already shows tool timing in the TUI (`Took Xs`), but that timing is UI-only.
13
13
 
14
14
  ## How it works
15
15
 
16
- The extension matches Pi `tool_execution_start` and `tool_result` events by `toolCallId`. When elapsed time is at or above the configured threshold, or a result reports a non-zero exit code, it appends one text block:
16
+ The extension measures from Pi's `tool_execution_start` through `tool_execution_end`. When elapsed time is at or above the configured threshold, or Pi marks the result as failed, it saves the timing in a hidden session entry. Before each model request, it appends one text block to that request's copy of the tool result:
17
17
 
18
18
  ```text
19
19
  [duration: 5.0s]
20
20
  ```
21
21
 
22
- Scope: Pi tools that emit `tool_result` events, including built-ins and extension tools. Direct `!` / `!!` shell commands and RPC `bash` command messages are not tool results and are not annotated.
22
+ Compaction summaries also receive annotated copies of tool results. Recorded tool output stays unchanged, including after reload, resume, and branch navigation. Timings remain available to the model across those transitions without duplicating Pi's native TUI timing such as `Took 5.0s`.
23
+
24
+ Markers already saved as tool text by versions through 0.2.1 remain unchanged. They cannot be safely distinguished from genuine tool output with the same text.
25
+
26
+ Scope: Pi tools that emit tool execution events, including built-ins and extension tools. Direct `!` / `!!` shell commands and RPC `bash` command messages are not tool results and are not annotated.
27
+
28
+ Pi starts these timers during sequential tool-call preflight. In a parallel batch, a call's elapsed time can therefore include time spent preparing later siblings. This mirrors Pi's TUI timing.
23
29
 
24
30
  ## Install
25
31
 
@@ -28,7 +34,7 @@ Requires Pi 0.84.0 or later.
28
34
  ```bash
29
35
  pi install . # local, global settings
30
36
  pi install -l --approve . # local, project settings
31
- pi install npm:pi-tool-duration # after npm publish
37
+ pi install npm:pi-tool-duration # published package
32
38
  ```
33
39
 
34
40
  ## Try without installing
@@ -36,21 +42,23 @@ pi install npm:pi-tool-duration # after npm publish
36
42
  From this repo:
37
43
 
38
44
  ```bash
39
- pi -e .
45
+ pi --no-extensions -e .
40
46
  # or
41
- pi -e ./extensions/tool-duration/index.ts
47
+ pi --no-extensions -e ./extensions/tool-duration/index.ts
42
48
  ```
43
49
 
50
+ `--no-extensions` prevents a duplicate flag conflict when another copy is already installed.
51
+
44
52
  ## Configure
45
53
 
46
54
  Default threshold: `1000` ms.
47
55
 
48
56
  ```bash
49
- PI_TOOL_DURATION_THRESHOLD_MS=0 pi -e . # annotate every tool result
50
- pi -e . --tool-duration-threshold-ms 500 # annotate tools taking >= 500ms
57
+ PI_TOOL_DURATION_THRESHOLD_MS=0 pi --no-extensions -e . # annotate every tool result
58
+ pi --no-extensions -e . --tool-duration-threshold-ms 500 # annotate tools taking >= 500ms
51
59
  ```
52
60
 
53
- Invalid threshold values fall back to the default.
61
+ Invalid values are ignored. An invalid CLI value falls through to the environment value; an invalid environment value falls back to the default.
54
62
 
55
63
  ## Verify
56
64
 
@@ -67,7 +75,7 @@ hi
67
75
  [duration: 5.0s]
68
76
  ```
69
77
 
70
- A fast successful command below the threshold stays unchanged. A non-zero exit code is always annotated, even below the threshold.
78
+ A fast successful tool below the threshold stays unchanged. A failed tool result delivered to the model is always annotated, even below the threshold.
71
79
 
72
80
  ## License
73
81
 
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * pi-tool-duration
3
3
  *
4
- * Appends `[duration: Xs]` to slow tool results so the model sees how long a
5
- * call actually took. pi already measures this for the TUI ("Took Xs") but the
6
- * model does not see that timing.
4
+ * Appends `[duration: Xs]` to slow or failed tool messages so the model sees
5
+ * how long a call actually took. pi already measures this for the TUI
6
+ * ("Took Xs") but the model does not see that timing.
7
7
  */
8
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import type { ContextEvent, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
9
 
10
10
  const DEFAULT_THRESHOLD_MS = 1000;
11
- const DURATION_RE = /^\[duration: \d+(?:\.\d+)?s\]$/;
11
+ const TIMING_ENTRY = "pi-tool-duration";
12
+
13
+ type SavedTiming = { toolCallId: string; timestamp: number; duration: string };
12
14
 
13
15
  function parseThreshold(value: unknown): number | undefined {
14
16
  if (typeof value !== "string" && typeof value !== "number") return undefined;
@@ -25,56 +27,82 @@ function thresholdMs(pi: ExtensionAPI): number {
25
27
  );
26
28
  }
27
29
 
28
- function alreadyAnnotated(content: Array<{ type: string; text?: string }>): boolean {
29
- const last = content.at(-1);
30
- return last?.type === "text" && typeof last.text === "string" && DURATION_RE.test(last.text);
31
- }
32
-
33
- function nonZeroExitCode(event: { content: Array<{ type: string; text?: string }>; details?: unknown }): boolean {
34
- const details = event.details as Record<string, unknown> | null;
35
- const code = details && Number(details.exitCode ?? details.code ?? details.status);
36
- if (code !== null && Number.isFinite(code)) return code !== 0;
30
+ function withDurations(messages: ContextEvent["messages"], ctx: ExtensionContext) {
31
+ const saved = new Map<string, string>();
32
+ for (const entry of ctx.sessionManager.getBranch()) {
33
+ if (entry.type !== "custom" || entry.customType !== TIMING_ENTRY) continue;
34
+ const timing = entry.data as SavedTiming | undefined;
35
+ if (
36
+ typeof timing?.toolCallId !== "string" ||
37
+ typeof timing.timestamp !== "number" ||
38
+ typeof timing.duration !== "string"
39
+ ) continue;
40
+ saved.set(`${timing.timestamp}:${timing.toolCallId}`, timing.duration);
41
+ }
37
42
 
38
- return event.content.some(
39
- (item) =>
40
- item.type === "text" &&
41
- typeof item.text === "string" &&
42
- /(?:exited with code|exit code)\s+(-?[1-9]\d*)\b/i.test(item.text),
43
- );
43
+ return messages.map((message) => {
44
+ if (message.role !== "toolResult") return message;
45
+ const duration = saved.get(`${message.timestamp}:${message.toolCallId}`);
46
+ if (!duration) return message;
47
+ return {
48
+ ...message,
49
+ content: [...message.content, { type: "text" as const, text: duration }],
50
+ };
51
+ });
44
52
  }
45
53
 
46
54
  export default function (pi: ExtensionAPI) {
47
55
  const starts = new Map<string, number>();
56
+ const durations = new Map<string, string>();
48
57
 
49
58
  pi.registerFlag("tool-duration-threshold-ms", {
50
- description: "Minimum tool duration in milliseconds before appending [duration: Xs] to the model-visible result",
59
+ // Pi's help formatter adds no separator once this long flag exceeds its 30-column width.
60
+ description: " Minimum elapsed milliseconds before appending a model-visible duration",
51
61
  type: "string",
52
62
  });
53
63
 
54
- pi.on("tool_execution_start", async (event) => {
64
+ pi.on("tool_execution_start", (event) => {
55
65
  starts.set(event.toolCallId, performance.now());
56
66
  });
57
67
 
58
- pi.on("tool_result", async (event) => {
68
+ pi.on("tool_execution_end", (event) => {
59
69
  const startedAt = starts.get(event.toolCallId);
60
70
  starts.delete(event.toolCallId);
61
71
  if (startedAt === undefined) return;
62
72
 
63
73
  const ms = performance.now() - startedAt;
64
- if (!nonZeroExitCode(event) && ms < thresholdMs(pi)) return;
65
- if (alreadyAnnotated(event.content)) return;
74
+ if (!event.isError && ms < thresholdMs(pi)) return;
75
+ durations.set(event.toolCallId, `[duration: ${(ms / 1000).toFixed(1)}s]`);
76
+ });
66
77
 
67
- return {
68
- content: [
69
- ...event.content,
70
- { type: "text" as const, text: `[duration: ${(ms / 1000).toFixed(1)}s]` },
71
- ],
72
- };
78
+ pi.on("message_end", (event) => {
79
+ if (event.message.role !== "toolResult") return;
80
+ const duration = durations.get(event.message.toolCallId);
81
+ durations.delete(event.message.toolCallId);
82
+ if (!duration) return;
83
+
84
+ // Keep timing out of recorded tool content: Pi also uses it to rebuild the TUI.
85
+ pi.appendEntry<SavedTiming>(TIMING_ENTRY, {
86
+ toolCallId: event.message.toolCallId,
87
+ timestamp: event.message.timestamp,
88
+ duration,
89
+ });
90
+ });
91
+
92
+ pi.on("context", (event, ctx) => ({ messages: withDurations(event.messages, ctx) }));
93
+
94
+ pi.on("session_before_compact", ({ preparation }, ctx) => {
95
+ // Native summarization bypasses context hooks. Replace its inputs, never the saved messages.
96
+ preparation.messagesToSummarize = withDurations(preparation.messagesToSummarize, ctx);
97
+ preparation.turnPrefixMessages = withDurations(preparation.turnPrefixMessages, ctx);
73
98
  });
74
99
 
75
- const clearStarts = () => starts.clear();
76
- pi.on("session_start", clearStarts);
77
- pi.on("session_shutdown", clearStarts);
78
- pi.on("agent_end", clearStarts);
79
- pi.on("agent_settled", clearStarts);
100
+ const clearTimings = () => {
101
+ starts.clear();
102
+ durations.clear();
103
+ };
104
+ pi.on("session_start", clearTimings);
105
+ pi.on("session_shutdown", clearTimings);
106
+ pi.on("agent_end", clearTimings);
107
+ pi.on("agent_settled", clearTimings);
80
108
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tool-duration",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Append model-visible durations to slow Pi tool results",
5
5
  "type": "module",
6
6
  "author": "Mitch Fultz (https://github.com/fitchmultz)",
@@ -41,10 +41,11 @@
41
41
  },
42
42
  "devDependencies": {
43
43
  "@earendil-works/pi-coding-agent": "0.84.0",
44
+ "typebox": "1.3.7",
44
45
  "typescript": "^5.9.3"
45
46
  },
46
47
  "peerDependencies": {
47
- "@earendil-works/pi-coding-agent": "*"
48
+ "@earendil-works/pi-coding-agent": ">=0.84.0"
48
49
  },
49
50
  "peerDependenciesMeta": {
50
51
  "@earendil-works/pi-coding-agent": {