@signalridge/pi-worktime 1.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+ ### Major Changes
5
+
6
+ - Adopt a unified 1.0.0 across every published package.
7
+
8
+ The version numbers no longer track their upstream forks individually; from this release each package
9
+ is versioned on its own merit against the Signalridge line, and 1.0.0 is the shared starting point.
10
+ Packages whose behavior changed in this release document that change in their own changeset entries;
11
+ the remainder are re-released unchanged so the whole set shares one baseline.
12
+ - Initial release of the `@signalridge/pi-worktime` package for per-prompt, branch-scoped active worktime tracking.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 tomsej
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # pi-worktime
2
+
3
+ `@signalridge/pi-worktime` tracks active agent time for the current prompt. It
4
+ accumulates only the spans between `agent_start` and `agent_end`, and resets when
5
+ an accepted user message starts (`message_start` with `message.role === "user"`).
6
+ Preflight `input` is intentionally not used, so rejected or handled input does
7
+ not erase the current worktime. The current value is exposed through Pi's
8
+ namespaced `worktime:update` event.
9
+
10
+ In TUI sessions it owns the `worktime` status key with a compact value such as
11
+ `2m 5s`. The `/worktime` command reports the same value when Pi provides a UI.
12
+ There is no persistence or settings file; print, JSON, and RPC sessions still
13
+ receive validated event-bus updates without invoking TUI-only status APIs.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pi install npm:@signalridge/pi-worktime
19
+ ```
20
+
21
+ ## Use from this checkout
22
+
23
+ From the repository root:
24
+
25
+ ```bash
26
+ pi -e ./packages/pi-worktime
27
+ ```
28
+
29
+ ## Public imports
30
+
31
+ The package exports typed source entrypoints for both extension loading and
32
+ library consumers:
33
+
34
+ ```ts
35
+ import worktime, { WORKTIME_UPDATE_EVENT } from "@signalridge/pi-worktime";
36
+ import {
37
+ parseWorktimeUpdatePayload,
38
+ type WorktimeUpdatePayload,
39
+ } from "@signalridge/pi-worktime/events";
40
+ ```
41
+
42
+ ## Event contract
43
+
44
+ Consumers can subscribe to `worktime:update` through `pi.events`. Payloads are
45
+ immutable exact objects of the following shape:
46
+
47
+ ```ts
48
+ { ms: number; running: boolean }
49
+ ```
50
+
51
+ `ms` is finite and nonnegative. `parseWorktimeUpdatePayload()` returns a fresh
52
+ frozen normalized payload or `undefined`; `isWorktimeUpdatePayload()` validates
53
+ without executing accessors and rejects proxies, extra keys, and invalid values.
54
+
55
+ ## Provenance
56
+
57
+ The active-span behavior and compact formatter are adapted from tomsej's
58
+ MIT-licensed `extensions/worktime` extension in
59
+ [`tomsej/pi-ext`](https://github.com/tomsej/pi-ext), original worktime commit
60
+ `204182c`. This package is an independent TypeScript implementation with
61
+ session-safe lifecycle handling, validated immutable events, and TUI/headless
62
+ guards.
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@signalridge/pi-worktime",
3
+ "version": "1.0.0",
4
+ "description": "Per-prompt active worktime tracking for Pi.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "pi",
12
+ "worktime",
13
+ "active-time",
14
+ "productivity"
15
+ ],
16
+ "piExtension": {
17
+ "lifecycle": "stable"
18
+ },
19
+ "peerDependencies": {
20
+ "@earendil-works/pi-coding-agent": "*"
21
+ },
22
+ "devDependencies": {
23
+ "@biomejs/biome": "^2.5.7",
24
+ "@earendil-works/pi-coding-agent": "0.84.1",
25
+ "@types/node": "^24.10.0",
26
+ "typescript": "^6.0.0",
27
+ "vitest": "^4.0.18"
28
+ },
29
+ "pi": {
30
+ "extensions": [
31
+ "./src/index.ts"
32
+ ]
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/signalridge/pi-extensions",
37
+ "directory": "packages/pi-worktime"
38
+ },
39
+ "homepage": "https://github.com/signalridge/pi-extensions/tree/main/packages/pi-worktime",
40
+ "bugs": {
41
+ "url": "https://github.com/signalridge/pi-extensions/issues"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "exports": {
47
+ ".": {
48
+ "types": "./src/index.ts",
49
+ "default": "./src/index.ts"
50
+ },
51
+ "./events": {
52
+ "types": "./src/events.ts",
53
+ "default": "./src/events.ts"
54
+ }
55
+ },
56
+ "files": [
57
+ "README.md",
58
+ "LICENSE",
59
+ "CHANGELOG.md",
60
+ "src"
61
+ ],
62
+ "scripts": {
63
+ "typecheck": "tsc --noEmit",
64
+ "lint": "biome check .",
65
+ "test": "vitest run test --config vitest.config.ts",
66
+ "check": "bun run lint && bun run typecheck && bun run test",
67
+ "format": "biome check --write ."
68
+ }
69
+ }
package/src/core.ts ADDED
@@ -0,0 +1,120 @@
1
+ import { performance } from "node:perf_hooks";
2
+
3
+ /** A clock returning a monotonic millisecond value. */
4
+ export type WorktimeClock = () => number;
5
+
6
+ export interface Worktime {
7
+ /** Reset accumulated active time; a running span remains running from now. */
8
+ reset(): void;
9
+ /** Start an active span. Repeated calls do not restart the span. */
10
+ start(): void;
11
+ /** End the active span. Repeated calls do not add time. */
12
+ end(): void;
13
+ /** Return the accumulated active time in milliseconds. */
14
+ elapsed(): number;
15
+ readonly running: boolean;
16
+ }
17
+
18
+ const MAX_DURATION_MS = Number.MAX_VALUE;
19
+
20
+ function readClock(clock: WorktimeClock): number | undefined {
21
+ try {
22
+ const value = clock();
23
+ return Number.isFinite(value) ? value : undefined;
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ }
28
+
29
+ function clampDuration(value: number): number {
30
+ if (value <= 0 || Number.isNaN(value)) return 0;
31
+ if (value === Number.POSITIVE_INFINITY) return MAX_DURATION_MS;
32
+ if (!Number.isFinite(value)) return 0;
33
+ return value;
34
+ }
35
+
36
+ function addDurations(left: number, right: number): number {
37
+ const sum = left + right;
38
+ return Number.isFinite(sum) ? clampDuration(sum) : MAX_DURATION_MS;
39
+ }
40
+
41
+ function activeSpan(start: number | undefined, current: number | undefined): number {
42
+ if (start === undefined || current === undefined) return 0;
43
+ const difference = current - start;
44
+ if (difference === Number.POSITIVE_INFINITY) return MAX_DURATION_MS;
45
+ if (!Number.isFinite(difference) || difference <= 0) return 0;
46
+ return difference;
47
+ }
48
+
49
+ /**
50
+ * Create an active-time state machine.
51
+ *
52
+ * Time is accumulated only while running. Invalid clock samples contribute no
53
+ * time, and injected samples are normalized to a nondecreasing sequence so a
54
+ * wall-clock correction cannot make the public duration regress. The default
55
+ * clock is monotonic; the clock is injectable so lifecycle behavior can be
56
+ * deterministic in tests without replacing global time APIs.
57
+ */
58
+ export function createWorktime(clock: WorktimeClock = () => performance.now()): Worktime {
59
+ let totalMs = 0;
60
+ let running = false;
61
+ let runStart: number | undefined;
62
+ let lastClockSample: number | undefined;
63
+
64
+ const readSample = (): number | undefined => {
65
+ const sample = readClock(clock);
66
+ if (sample === undefined) return undefined;
67
+ if (lastClockSample !== undefined && sample < lastClockSample) return lastClockSample;
68
+ lastClockSample = sample;
69
+ return sample;
70
+ };
71
+
72
+ return {
73
+ reset(): void {
74
+ totalMs = 0;
75
+ runStart = running ? readSample() : undefined;
76
+ },
77
+
78
+ start(): void {
79
+ if (running) return;
80
+ running = true;
81
+ runStart = readSample();
82
+ },
83
+
84
+ end(): void {
85
+ if (!running) return;
86
+ totalMs = addDurations(totalMs, activeSpan(runStart, readSample() ?? lastClockSample));
87
+ running = false;
88
+ runStart = undefined;
89
+ },
90
+
91
+ elapsed(): number {
92
+ if (!running) return totalMs;
93
+ const current = readSample();
94
+ if (runStart === undefined) {
95
+ // An invalid sample cannot establish a span. A later valid sample is a
96
+ // safe new baseline rather than an invitation to count unknown time.
97
+ runStart = current;
98
+ return totalMs;
99
+ }
100
+ return addDurations(totalMs, activeSpan(runStart, current ?? lastClockSample));
101
+ },
102
+
103
+ get running(): boolean {
104
+ return running;
105
+ },
106
+ };
107
+ }
108
+
109
+ /** Format a duration compactly as `1h 2m 3s`, `2m 3s`, or `3s`. */
110
+ export function formatDuration(milliseconds: number): string {
111
+ const duration = Number.isFinite(milliseconds) && milliseconds >= 0 ? milliseconds : 0;
112
+ const totalSeconds = Math.floor(duration / 1000);
113
+ const hours = Math.floor(totalSeconds / 3600);
114
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
115
+ const seconds = totalSeconds % 60;
116
+
117
+ if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
118
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
119
+ return `${seconds}s`;
120
+ }
package/src/events.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { types as nodeTypes } from "node:util";
2
+
3
+ export const WORKTIME_UPDATE_EVENT = "worktime:update";
4
+
5
+ export interface WorktimeUpdatePayload {
6
+ ms: number;
7
+ running: boolean;
8
+ }
9
+
10
+ type PlainObject = { [key: PropertyKey]: unknown };
11
+
12
+ function isPlainObject(value: unknown): value is PlainObject {
13
+ if (typeof value !== "object" || value === null) return false;
14
+
15
+ try {
16
+ if (nodeTypes.isProxy(value) || Array.isArray(value)) return false;
17
+ const prototype = Object.getPrototypeOf(value);
18
+ return prototype === Object.prototype || prototype === null;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ function normalizeWorktimeUpdatePayload(value: unknown): WorktimeUpdatePayload | undefined {
25
+ if (!isPlainObject(value)) return undefined;
26
+
27
+ try {
28
+ const keys = Reflect.ownKeys(value);
29
+ if (keys.length !== 2 || !keys.includes("ms") || !keys.includes("running")) return undefined;
30
+
31
+ const milliseconds = Object.getOwnPropertyDescriptor(value, "ms");
32
+ const running = Object.getOwnPropertyDescriptor(value, "running");
33
+ if (!milliseconds || !running || !("value" in milliseconds) || !("value" in running)) return undefined;
34
+
35
+ if (
36
+ typeof milliseconds.value !== "number" ||
37
+ !Number.isFinite(milliseconds.value) ||
38
+ milliseconds.value < 0 ||
39
+ typeof running.value !== "boolean"
40
+ ) {
41
+ return undefined;
42
+ }
43
+
44
+ return Object.freeze({
45
+ ms: milliseconds.value,
46
+ running: running.value,
47
+ });
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Parse and normalize an event payload into a fresh immutable snapshot.
55
+ * Proxies, accessors, extra keys, and invalid field values are rejected.
56
+ */
57
+ export function parseWorktimeUpdatePayload(value: unknown): WorktimeUpdatePayload | undefined {
58
+ return normalizeWorktimeUpdatePayload(value);
59
+ }
60
+
61
+ /** Validate the exact, JSON-shaped payload published on the worktime bus. */
62
+ export function isWorktimeUpdatePayload(value: unknown): value is WorktimeUpdatePayload {
63
+ return normalizeWorktimeUpdatePayload(value) !== undefined;
64
+ }
65
+
66
+ /** Alias for callers that prefer validator terminology. */
67
+ export function validateWorktimeUpdatePayload(value: unknown): value is WorktimeUpdatePayload {
68
+ return isWorktimeUpdatePayload(value);
69
+ }
package/src/index.ts ADDED
@@ -0,0 +1,240 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { createWorktime, formatDuration, type WorktimeClock } from "./core.js";
3
+ import { parseWorktimeUpdatePayload, WORKTIME_UPDATE_EVENT, type WorktimeUpdatePayload } from "./events.js";
4
+
5
+ const WORKTIME_STATUS_KEY = "worktime";
6
+ const UPDATE_INTERVAL_MS = 1_000;
7
+ type IntervalHandle = ReturnType<typeof setInterval>;
8
+ type IntervalScheduler = (callback: () => void, delay: number) => IntervalHandle;
9
+ type IntervalCanceller = (handle: IntervalHandle) => void;
10
+
11
+ export interface WorktimeExtensionOptions {
12
+ clock?: WorktimeClock;
13
+ setInterval?: IntervalScheduler;
14
+ clearInterval?: IntervalCanceller;
15
+ }
16
+
17
+ function sessionManagerOf(ctx: ExtensionContext): ExtensionContext["sessionManager"] | undefined {
18
+ try {
19
+ return ctx.sessionManager;
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ }
24
+
25
+ function unrefInterval(handle: IntervalHandle): void {
26
+ if ((typeof handle !== "object" && typeof handle !== "function") || handle === null) return;
27
+
28
+ try {
29
+ const unref = (handle as unknown as { unref?: unknown }).unref;
30
+ if (typeof unref === "function") unref.call(handle);
31
+ } catch {
32
+ // Browser-like or test schedulers may not expose a native unref handle.
33
+ }
34
+ }
35
+
36
+ /** Register the worktime lifecycle handlers, with clock/timer seams for tests. */
37
+ export function registerWorktime(pi: ExtensionAPI, options: WorktimeExtensionOptions = {}): void {
38
+ const worktime = createWorktime(options.clock);
39
+ const schedule = options.setInterval ?? ((callback, delay) => setInterval(callback, delay));
40
+ const cancel = options.clearInterval ?? ((handle) => clearInterval(handle));
41
+
42
+ let active = false;
43
+ let sessionGeneration = 0;
44
+ let activeSessionManager: ExtensionContext["sessionManager"] | undefined;
45
+ let activeContext: ExtensionContext | undefined;
46
+ let interval: IntervalHandle | undefined;
47
+ let intervalToken = 0;
48
+
49
+ const ownsSession = (ctx: ExtensionContext): boolean => {
50
+ if (!active || activeSessionManager === undefined) return false;
51
+ return sessionManagerOf(ctx) === activeSessionManager;
52
+ };
53
+
54
+ const clearTimer = (): void => {
55
+ intervalToken += 1;
56
+ const current = interval;
57
+ interval = undefined;
58
+ if (current === undefined) return;
59
+ try {
60
+ cancel(current);
61
+ } catch {
62
+ // Timer teardown is best effort during host shutdown.
63
+ }
64
+ };
65
+
66
+ const updateStatus = (ctx: ExtensionContext, payload: WorktimeUpdatePayload): void => {
67
+ try {
68
+ if (ctx.mode !== "tui") return;
69
+ ctx.ui.setStatus(WORKTIME_STATUS_KEY, formatDuration(payload.ms));
70
+ } catch {
71
+ // UI teardown can race a lifecycle callback; event publication remains valid.
72
+ }
73
+ };
74
+
75
+ const clearOwnedStatus = (ctx: ExtensionContext): void => {
76
+ try {
77
+ if (ctx.mode !== "tui") return;
78
+ ctx.ui.setStatus(WORKTIME_STATUS_KEY, undefined);
79
+ } catch {
80
+ // UI teardown can race a lifecycle callback; clearing is best-effort.
81
+ }
82
+ };
83
+
84
+ const deactivateStaleSession = (capturedManager: ExtensionContext["sessionManager"], generation: number): void => {
85
+ if (!active || generation !== sessionGeneration || activeSessionManager !== capturedManager) return;
86
+ clearTimer();
87
+ worktime.end();
88
+ active = false;
89
+ activeSessionManager = undefined;
90
+ activeContext = undefined;
91
+ // Do not publish or call UI APIs through a context Pi has invalidated.
92
+ };
93
+
94
+ const publish = (ctx: ExtensionContext): void => {
95
+ if (!ownsSession(ctx)) return;
96
+
97
+ const milliseconds = worktime.elapsed();
98
+ const running = worktime.running;
99
+ const eventPayload = parseWorktimeUpdatePayload({ ms: milliseconds, running });
100
+ const statusPayload = parseWorktimeUpdatePayload({ ms: milliseconds, running });
101
+ if (eventPayload === undefined || statusPayload === undefined) return;
102
+ const generation = sessionGeneration;
103
+
104
+ try {
105
+ pi.events.emit(WORKTIME_UPDATE_EVENT, eventPayload);
106
+ } catch {
107
+ // A third-party event listener must not prevent timer or session cleanup.
108
+ }
109
+ if (active && generation === sessionGeneration && ownsSession(ctx)) updateStatus(ctx, statusPayload);
110
+ };
111
+
112
+ const startTimer = (): void => {
113
+ if (interval !== undefined) return;
114
+ const generation = sessionGeneration;
115
+ const capturedContext = activeContext;
116
+ const capturedManager = activeSessionManager;
117
+ if (capturedContext === undefined || capturedManager === undefined) return;
118
+ const token = ++intervalToken;
119
+ const handle = schedule(() => {
120
+ if (token !== intervalToken || !active || generation !== sessionGeneration) return;
121
+ if (sessionManagerOf(capturedContext) !== capturedManager || activeSessionManager !== capturedManager) {
122
+ deactivateStaleSession(capturedManager, generation);
123
+ return;
124
+ }
125
+ const ctx = activeContext;
126
+ if (ctx === undefined || !ownsSession(ctx)) {
127
+ deactivateStaleSession(capturedManager, generation);
128
+ return;
129
+ }
130
+ if (!worktime.running) return;
131
+ publish(ctx);
132
+ }, UPDATE_INTERVAL_MS);
133
+ interval = handle;
134
+ unrefInterval(handle);
135
+ };
136
+
137
+ pi.on("session_start", (_event, ctx) => {
138
+ const sessionManager = sessionManagerOf(ctx);
139
+ if (sessionManager === undefined) return;
140
+ sessionGeneration += 1;
141
+ clearTimer();
142
+ worktime.end();
143
+ worktime.reset();
144
+ active = true;
145
+ activeSessionManager = sessionManager;
146
+ activeContext = ctx;
147
+ publish(ctx);
148
+ });
149
+
150
+ pi.on("session_tree", (_event, ctx) => {
151
+ const sessionManager = sessionManagerOf(ctx);
152
+ if (sessionManager === undefined) return;
153
+ sessionGeneration += 1;
154
+ clearTimer();
155
+ worktime.end();
156
+ worktime.reset();
157
+ active = true;
158
+ activeSessionManager = sessionManager;
159
+ activeContext = ctx;
160
+ publish(ctx);
161
+ });
162
+
163
+ pi.on("message_start", (event, ctx) => {
164
+ if (event.message.role !== "user" || !ownsSession(ctx)) return;
165
+ activeContext = ctx;
166
+ worktime.reset();
167
+ publish(ctx);
168
+ });
169
+
170
+ pi.on("agent_start", (_event, ctx) => {
171
+ if (!ownsSession(ctx)) return;
172
+ activeContext = ctx;
173
+ worktime.start();
174
+ startTimer();
175
+ publish(ctx);
176
+ });
177
+
178
+ pi.on("agent_end", (_event, ctx) => {
179
+ if (!ownsSession(ctx)) return;
180
+ activeContext = ctx;
181
+ worktime.end();
182
+ clearTimer();
183
+ publish(ctx);
184
+ });
185
+
186
+ pi.on("session_shutdown", (_event, ctx) => {
187
+ if (!ownsSession(ctx)) return;
188
+ const shutdownGeneration = sessionGeneration + 1;
189
+ sessionGeneration = shutdownGeneration;
190
+ clearTimer();
191
+ worktime.end();
192
+ publish(ctx);
193
+ // An event listener can synchronously begin a replacement session. Do not
194
+ // clear its status or deactivate its state from this old shutdown.
195
+ if (sessionGeneration !== shutdownGeneration || !active || !ownsSession(ctx)) return;
196
+ active = false;
197
+ activeSessionManager = undefined;
198
+ activeContext = undefined;
199
+ clearOwnedStatus(ctx);
200
+ });
201
+
202
+ pi.registerCommand("worktime", {
203
+ description: "Show how long the agent worked on the current prompt",
204
+ handler: async (_args, ctx) => {
205
+ let hasUI = false;
206
+ try {
207
+ hasUI = ctx.hasUI;
208
+ } catch {
209
+ return;
210
+ }
211
+ if (!hasUI) return;
212
+ if (!active || !ownsSession(ctx)) {
213
+ try {
214
+ ctx.ui.notify("Worktime unavailable: not initialized for this session.", "warning");
215
+ } catch {
216
+ // A stale UI context may already have been disposed.
217
+ }
218
+ return;
219
+ }
220
+ try {
221
+ ctx.ui.notify(`Agent worked ${formatDuration(worktime.elapsed())} on this prompt`, "info");
222
+ } catch {
223
+ // UI teardown should not turn an informational command into a failure.
224
+ }
225
+ },
226
+ });
227
+ }
228
+
229
+ export default function worktime(pi: ExtensionAPI): void {
230
+ registerWorktime(pi);
231
+ }
232
+
233
+ export { createWorktime, formatDuration, type Worktime, type WorktimeClock } from "./core.js";
234
+ export type { WorktimeUpdatePayload } from "./events.js";
235
+ export {
236
+ isWorktimeUpdatePayload,
237
+ parseWorktimeUpdatePayload,
238
+ validateWorktimeUpdatePayload,
239
+ WORKTIME_UPDATE_EVENT,
240
+ } from "./events.js";