@hk_net/pi-timestamp 0.1.5 → 0.1.7

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 (3) hide show
  1. package/README.md +3 -2
  2. package/package.json +3 -3
  3. package/timestamp.ts +147 -1
package/README.md CHANGED
@@ -1,13 +1,14 @@
1
1
  # pi-timestamp
2
2
 
3
- Shows timestamps for user input and agent completion timing.
3
+ Shows timestamps for user input and agent completion timing. Requires pi `>=0.80.10`.
4
4
 
5
5
  ## What it does
6
6
 
7
7
  - **User input**: Shows `Sent HH:MM:SS` as a dim status line in the chat display after each user message
8
8
  - **Agent completion**: Shows `Done at HH:MM:SS · duration` as a dim status line in the chat display after each agent turn (e.g., `Done at 14:32:05 · 3.2s`)
9
+ - **Session/runtime summaries**: Shows accent-colored summaries when switching sessions and at final Pi exit, including start/end times and durations. The final summary includes the complete Pi process runtime and every session interval.
9
10
 
10
- All timestamps are **display-only** — they are shown via Pi's UI notification/status rendering and never enter the LLM context.
11
+ All timestamps and summaries are **display-only** — session-switch summaries render in Pi's UI and the final runtime summary prints after Pi restores the terminal in TUI mode. They never enter the LLM context.
11
12
 
12
13
  ## Display behavior
13
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hk_net/pi-timestamp",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Pi extension that shows timestamps for user input and agent completion timing",
5
5
  "type": "module",
6
6
  "private": false,
@@ -26,8 +26,8 @@
26
26
  "README.md"
27
27
  ],
28
28
  "peerDependencies": {
29
- "@earendil-works/pi-coding-agent": ">=0.80.2",
30
- "@earendil-works/pi-tui": ">=0.80.2"
29
+ "@earendil-works/pi-coding-agent": ">=0.80.10",
30
+ "@earendil-works/pi-tui": ">=0.80.10"
31
31
  },
32
32
  "pi": {
33
33
  "extensions": [
package/timestamp.ts CHANGED
@@ -6,9 +6,63 @@
6
6
  *
7
7
  * - Shows `Sent HH:MM:SS` after each user message in the chat UI
8
8
  * - Shows `Done at HH:MM:SS · duration` after each agent turn in the chat UI
9
+ * - Summarizes completed sessions and the complete Pi process runtime
9
10
  */
10
11
 
11
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+
14
+ export interface SessionInterval {
15
+ id: string;
16
+ startedAt: number;
17
+ endedAt: number;
18
+ }
19
+
20
+ export interface TimestampRuntimeState {
21
+ processStartedAt: number;
22
+ activeSession: { id: string; startedAt: number } | undefined;
23
+ completedSessions: SessionInterval[];
24
+ pendingSessionSummary: SessionInterval | undefined;
25
+ }
26
+
27
+ const RUNTIME_STATE_KEY = Symbol.for("hknet.pi-timestamp.runtime-state");
28
+
29
+ export function createRuntimeState(processStartedAt: number): TimestampRuntimeState {
30
+ return { processStartedAt, activeSession: undefined, completedSessions: [], pendingSessionSummary: undefined };
31
+ }
32
+
33
+ function getRuntimeState(): TimestampRuntimeState {
34
+ const globalStore = globalThis as Record<symbol, unknown>;
35
+ const existing = globalStore[RUNTIME_STATE_KEY];
36
+ if (existing && typeof existing === "object") return existing as TimestampRuntimeState;
37
+
38
+ // The extension can load after Pi has initialized. Anchor the summary to the
39
+ // Node process start rather than the extension load time.
40
+ const state = createRuntimeState(Date.now() - process.uptime() * 1000);
41
+ globalStore[RUNTIME_STATE_KEY] = state;
42
+ return state;
43
+ }
44
+
45
+ export function beginSession(state: TimestampRuntimeState, id: string, startedAt: number): boolean {
46
+ if (state.activeSession?.id === id) return false;
47
+ state.activeSession = { id, startedAt };
48
+ return true;
49
+ }
50
+
51
+ export function finishSession(state: TimestampRuntimeState, endedAt: number): SessionInterval | undefined {
52
+ const active = state.activeSession;
53
+ if (!active) return undefined;
54
+
55
+ const interval = { ...active, endedAt };
56
+ state.completedSessions.push(interval);
57
+ state.activeSession = undefined;
58
+ return interval;
59
+ }
60
+
61
+ export function takePendingSessionSummary(state: TimestampRuntimeState): SessionInterval | undefined {
62
+ const pending = state.pendingSessionSummary;
63
+ state.pendingSessionSummary = undefined;
64
+ return pending;
65
+ }
12
66
 
13
67
  function formatTime(ts: number): string {
14
68
  const d = new Date(ts);
@@ -18,6 +72,27 @@ function formatTime(ts: number): string {
18
72
  return `${hh}:${mm}:${ss}`;
19
73
  }
20
74
 
75
+ function formatDateTime(ts: number): string {
76
+ const d = new Date(ts);
77
+ const yyyy = d.getFullYear();
78
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
79
+ const dd = String(d.getDate()).padStart(2, "0");
80
+ return `${yyyy}-${mm}-${dd} ${formatTime(ts)}`;
81
+ }
82
+
83
+ function isSameLocalDate(left: number, right: number): boolean {
84
+ const a = new Date(left);
85
+ const b = new Date(right);
86
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
87
+ }
88
+
89
+ function formatSessionRange(session: SessionInterval): string {
90
+ if (isSameLocalDate(session.startedAt, session.endedAt)) {
91
+ return `${formatTime(session.startedAt)}–${formatTime(session.endedAt)}`;
92
+ }
93
+ return `${formatDateTime(session.startedAt)} → ${formatDateTime(session.endedAt)}`;
94
+ }
95
+
21
96
  function formatDuration(ms: number): string {
22
97
  if (ms < 1000) return `${ms}ms`;
23
98
  const totalSecs = ms / 1000;
@@ -32,6 +107,27 @@ function formatDuration(ms: number): string {
32
107
  return `${Math.floor(hrs)}h ${Math.floor(totalMins % 60)}m`;
33
108
  }
34
109
 
110
+ export function formatRuntimeDuration(ms: number): string {
111
+ let remainingSeconds = Math.max(0, Math.floor(ms / 1000));
112
+ const weeks = Math.floor(remainingSeconds / (7 * 24 * 60 * 60));
113
+ remainingSeconds %= 7 * 24 * 60 * 60;
114
+ const days = Math.floor(remainingSeconds / (24 * 60 * 60));
115
+ remainingSeconds %= 24 * 60 * 60;
116
+ const hours = Math.floor(remainingSeconds / (60 * 60));
117
+ remainingSeconds %= 60 * 60;
118
+ const minutes = Math.floor(remainingSeconds / 60);
119
+ const seconds = remainingSeconds % 60;
120
+
121
+ const parts = [
122
+ weeks > 0 ? `${weeks}w` : undefined,
123
+ days > 0 ? `${days}d` : undefined,
124
+ hours > 0 ? `${hours}h` : undefined,
125
+ minutes > 0 ? `${minutes}m` : undefined,
126
+ `${seconds}s`,
127
+ ];
128
+ return parts.filter((part): part is string => part !== undefined).join(" ");
129
+ }
130
+
35
131
  function isTimeoutErrorMessage(message: string | undefined): boolean {
36
132
  return /timed? out|timeout/i.test(message ?? "");
37
133
  }
@@ -40,6 +136,10 @@ export default function (pi: ExtensionAPI) {
40
136
  let taskStartTime: number | undefined;
41
137
  let waitingForRetryAfterTimeout = false;
42
138
 
139
+ function notifyAccent(ctx: ExtensionContext, message: string): void {
140
+ ctx.ui.notify(ctx.ui.theme.fg("accent", message), "info");
141
+ }
142
+
43
143
  // Track when the agent starts processing. If Pi is auto-retrying after a timeout,
44
144
  // keep the original start time so the eventual completion covers the full task.
45
145
  pi.on("agent_start", async () => {
@@ -88,4 +188,50 @@ export default function (pi: ExtensionAPI) {
88
188
 
89
189
  ctx.ui.notify(`Done at ${formatTime(endTime)} · ${formatDuration(duration)}`, "info");
90
190
  });
191
+
192
+ pi.on("session_start", (_event, ctx) => {
193
+ const state = getRuntimeState();
194
+ const previousSession = takePendingSessionSummary(state);
195
+ if (previousSession) {
196
+ notifyAccent(
197
+ ctx,
198
+ `Previous session complete · ${formatSessionRange(previousSession)} · ${formatRuntimeDuration(previousSession.endedAt - previousSession.startedAt)}`,
199
+ );
200
+ }
201
+
202
+ const sessionId = ctx.sessionManager.getSessionId();
203
+ // A reload rebinds the extension to the same session. Keep its timer running.
204
+ beginSession(state, sessionId, Date.now());
205
+ });
206
+
207
+ pi.on("session_shutdown", (event, ctx) => {
208
+ // Reload replaces the extension runtime but not the Pi session.
209
+ if (event.reason === "reload") return;
210
+
211
+ const endTime = Date.now();
212
+ const interval = finishSession(getRuntimeState(), endTime);
213
+
214
+ if (event.reason !== "quit") {
215
+ if (interval) getRuntimeState().pendingSessionSummary = interval;
216
+ return;
217
+ }
218
+
219
+ const state = getRuntimeState();
220
+ const sessionRows = state.completedSessions.map(
221
+ (session, index) =>
222
+ ` ${index + 1}. ${formatSessionRange(session)} · ${formatRuntimeDuration(session.endedAt - session.startedAt)}`,
223
+ );
224
+ // On normal Ctrl+D shutdown Pi has already stopped the TUI, so ui.notify()
225
+ // cannot render. Write after terminal restoration instead.
226
+ const summary = [
227
+ "Pi runtime complete",
228
+ ` Started: ${formatDateTime(state.processStartedAt)}`,
229
+ ` Ended: ${formatDateTime(endTime)}`,
230
+ ` Total: ${formatRuntimeDuration(endTime - state.processStartedAt)}`,
231
+ ...(sessionRows.length > 0 ? [" Sessions:", ...sessionRows] : []),
232
+ ].join("\n");
233
+ if (ctx.mode === "tui") {
234
+ process.stdout.write(`${ctx.ui.theme.fg("accent", summary)}\n`);
235
+ }
236
+ });
91
237
  }