@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 +12 -0
- package/LICENSE +21 -0
- package/README.md +62 -0
- package/package.json +69 -0
- package/src/core.ts +120 -0
- package/src/events.ts +69 -0
- package/src/index.ts +240 -0
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";
|