kankaku 0.1.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/LICENSE +21 -0
- package/README.md +156 -0
- package/package.json +45 -0
- package/src/adapters/jsonl-work-log.ts +44 -0
- package/src/adapters/lazy-jsonl-work-log.ts +36 -0
- package/src/adapters/pi-tracker.ts +243 -0
- package/src/adapters/report.ts +133 -0
- package/src/config.ts +33 -0
- package/src/domain/intervals.ts +53 -0
- package/src/domain/task-view.ts +200 -0
- package/src/domain/work-record.ts +61 -0
- package/src/domain/work-tracker.ts +224 -0
- package/src/extension.ts +26 -0
- package/src/ports/clock.ts +3 -0
- package/src/ports/work-log.ts +6 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 soyunninja
|
|
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,156 @@
|
|
|
1
|
+
# kankaku
|
|
2
|
+
|
|
3
|
+
A [pi](https://pi.dev) extension that measures how long an agent actually
|
|
4
|
+
spends working on each prompt, so the time can later be accounted for
|
|
5
|
+
(billing, reporting).
|
|
6
|
+
|
|
7
|
+
## What it measures
|
|
8
|
+
|
|
9
|
+
For every prompt, kankaku tracks the span from `before_agent_start` to
|
|
10
|
+
`agent_settled` (or to `session_shutdown` if pi exits mid-run) and splits it
|
|
11
|
+
into:
|
|
12
|
+
|
|
13
|
+
- **`waitingMs`**: time pi spent blocked on the user — the union of
|
|
14
|
+
`ui_prompt_start/end` spans and the execution spans of configured
|
|
15
|
+
interactive tools (default `ask_user_question`, `ask_user_choice`). Union
|
|
16
|
+
avoids double-counting when a tool internally triggers a UI prompt.
|
|
17
|
+
- **`workMs`**: `wallMs - waitingMs`, the actual work time.
|
|
18
|
+
|
|
19
|
+
Every pi process — the orchestrator and any subagent child spawned by
|
|
20
|
+
`subagent_run` — records its own prompt-to-idle spans, tagged with a `role`
|
|
21
|
+
(`orchestrator` or `subagent`) and its `pid`/`parentPid`, so records can be
|
|
22
|
+
joined later.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
kankaku is a pi package. Pick one source:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
pi install npm:kankaku # from npm
|
|
30
|
+
pi install git:github.com/soyunninja/kankaku # from git (add @v0.1.0 to pin)
|
|
31
|
+
pi install /absolute/path/to/kankaku # local checkout, no copy
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`pi install` writes to your global `~/.pi/agent/settings.json`, so the
|
|
35
|
+
extension loads in every pi process, including the subagent children that
|
|
36
|
+
`subagent_run` spawns. Use `-l` to install into a project's `.pi/settings.json`
|
|
37
|
+
instead; note that project-local resources load only after the project is
|
|
38
|
+
trusted, which a subagent child may not inherit.
|
|
39
|
+
|
|
40
|
+
To try it without installing: `pi -e /absolute/path/to/kankaku`.
|
|
41
|
+
|
|
42
|
+
## Record schema
|
|
43
|
+
|
|
44
|
+
Each line in `worklog.jsonl` is one JSON object:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"schema": 1,
|
|
49
|
+
"id": "uuid",
|
|
50
|
+
"role": "orchestrator",
|
|
51
|
+
"pid": 4242,
|
|
52
|
+
"parentPid": 4000,
|
|
53
|
+
"project": "/abs/project/path",
|
|
54
|
+
"sessionId": "…",
|
|
55
|
+
"sessionFile": "…",
|
|
56
|
+
"mode": "tui",
|
|
57
|
+
"model": "anthropic/claude-opus",
|
|
58
|
+
"prompt": "first 200 chars of the first prompt",
|
|
59
|
+
"startedAt": "2026-09-10T16:00:00.000Z",
|
|
60
|
+
"settledAt": "2026-09-10T16:04:10.000Z",
|
|
61
|
+
"wallMs": 250000,
|
|
62
|
+
"waitingMs": 30000,
|
|
63
|
+
"workMs": 220000,
|
|
64
|
+
"runs": 2,
|
|
65
|
+
"turns": 9,
|
|
66
|
+
"tools": { "bash": 4, "read": 3, "subagent_run": 1, "ask_user_question": 1 },
|
|
67
|
+
"subagents": [{ "toolCallId": "…", "agent": "sdd-explore", "mode": "task", "taskId": "t1", "ms": 90000 }],
|
|
68
|
+
"usage": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "cost": 0 },
|
|
69
|
+
"status": "completed"
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`status` is one of `completed`, `aborted` (the last assistant message had
|
|
74
|
+
`stopReason: "aborted"`), or `interrupted` (pi shut down while still
|
|
75
|
+
running).
|
|
76
|
+
|
|
77
|
+
## Task and session views
|
|
78
|
+
|
|
79
|
+
Each `WorkRecord` still measures one pi process's own prompt-to-idle span.
|
|
80
|
+
But a `subagent_run` in `background` mode returns immediately while its
|
|
81
|
+
child process keeps working, so the orchestrator's own `wallMs` can
|
|
82
|
+
under-report how long the task actually took. Two derived, read-only views
|
|
83
|
+
correct for that, built purely from `pid`/`parentPid`/`startedAt`/`settledAt`
|
|
84
|
+
already present on every record — no new fields are persisted to
|
|
85
|
+
`worklog.jsonl`.
|
|
86
|
+
|
|
87
|
+
- **Task**: one orchestrator record plus every subagent record matched to
|
|
88
|
+
it — same `project`, `parentPid === orchestrator.pid`, and the child's
|
|
89
|
+
`startedAt` falling inside the orchestrator's `[startedAt, settledAt]`
|
|
90
|
+
window. (If a pid is reused across runs and several orchestrator records
|
|
91
|
+
match, the child attaches to the latest-starting one.) A task's `wallMs`
|
|
92
|
+
is the **union** of the orchestrator's interval and every matched child's
|
|
93
|
+
interval — never their sum — so parallel background children are not
|
|
94
|
+
double-counted, and a child that outlives the orchestrator's own settle
|
|
95
|
+
time correctly extends the task's span. `waitingMs` is the orchestrator's
|
|
96
|
+
own waiting time, `workMs = wallMs - waitingMs`, and `usage` is the sum of
|
|
97
|
+
the orchestrator's and every child's token/cost totals.
|
|
98
|
+
- **Session**: tasks grouped by `sessionId` (tasks with no `sessionId` are
|
|
99
|
+
grouped under `"unknown"`). A session's `wallMs` is the union of every
|
|
100
|
+
interval — orchestrator and subagent alike — across all of its tasks;
|
|
101
|
+
`waitingMs` is the sum of each task's `waitingMs`, and `workMs = wallMs -
|
|
102
|
+
waitingMs`.
|
|
103
|
+
- **Orphan subagents**: a subagent record with no matching orchestrator
|
|
104
|
+
record (for example, its parent's record was lost, or it belongs to a
|
|
105
|
+
different project) is excluded from every task but is not silently
|
|
106
|
+
dropped — it stays visible so gaps in the log are noticeable rather than
|
|
107
|
+
hidden.
|
|
108
|
+
|
|
109
|
+
## The `/kankaku` command
|
|
110
|
+
|
|
111
|
+
Run `/kankaku` inside pi to see today's totals (work, waiting, record count)
|
|
112
|
+
per role, plus a union-based tasks segment. In the interactive TUI the report
|
|
113
|
+
is appended to the chat transcript as a durable card that is never sent to
|
|
114
|
+
the LLM; without a UI (print or RPC mode) it falls back to a notification.
|
|
115
|
+
Arguments are whitespace-separated and order-insensitive:
|
|
116
|
+
|
|
117
|
+
- `/kankaku` — today's role totals and tasks segment, each with its estimated cost.
|
|
118
|
+
- `/kankaku all` — same, but across every record.
|
|
119
|
+
- `/kankaku tasks` — one line per task (time, union wall/work, cost,
|
|
120
|
+
subagent count, truncated prompt) for the **current pi session**. Add `all` for
|
|
121
|
+
every session. If the current session has no `sessionId`, tasks from every
|
|
122
|
+
session are shown instead.
|
|
123
|
+
- `/kankaku sessions` — one line per session (id, time range, union
|
|
124
|
+
wall/work, cost, task count) for today. Add `all` for every day.
|
|
125
|
+
|
|
126
|
+
Cost figures are the sum of `usage.cost` as priced by pi's model table
|
|
127
|
+
(per-million-token rates in `models.json`, adjustable with `modelOverrides`).
|
|
128
|
+
For subscription-based providers this is an estimate at API list prices, not
|
|
129
|
+
an invoice.
|
|
130
|
+
|
|
131
|
+
While an agent is running, pi's status bar shows a `⏱ mm:ss` indicator with
|
|
132
|
+
the elapsed time for the current run.
|
|
133
|
+
|
|
134
|
+
## Environment variables
|
|
135
|
+
|
|
136
|
+
- `KANKAKU_DIR`: directory for the work log, relative to the project cwd
|
|
137
|
+
unless given as an absolute path. Defaults to `.kankaku`.
|
|
138
|
+
- `KANKAKU_INTERACTIVE_TOOLS`: comma-separated list of tool names whose
|
|
139
|
+
execution span counts as waiting time. Defaults to
|
|
140
|
+
`ask_user_question,ask_user_choice`.
|
|
141
|
+
|
|
142
|
+
## Limitations
|
|
143
|
+
|
|
144
|
+
- A prompt shown by a tool that does not go through `ctx.ui` and is not
|
|
145
|
+
listed in `KANKAKU_INTERACTIVE_TOOLS` counts as work, not waiting time.
|
|
146
|
+
- Subagent totals are reported separately in the per-role summary and are
|
|
147
|
+
**not** summed into the orchestrator's `wallMs` there: task-mode subagents
|
|
148
|
+
run inside the parent's wall clock, and background subagents can outlive
|
|
149
|
+
the parent's idle moment, so naively adding them would double-count or
|
|
150
|
+
misrepresent billable time. Use the task/session views above (union-based,
|
|
151
|
+
never a sum) for a correct combined figure.
|
|
152
|
+
|
|
153
|
+
## Roadmap
|
|
154
|
+
|
|
155
|
+
- Remote sync service: the `id` and `schema` fields are already in place for
|
|
156
|
+
a future `synced` cursor that uploads records to a remote store.
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kankaku",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "pi extension that records agent work time per prompt, excluding waits for the user, with subagent linkage and task/session views",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "soyunninja",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/soyunninja/kankaku.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/soyunninja/kankaku#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/soyunninja/kankaku/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi",
|
|
19
|
+
"pi-extension",
|
|
20
|
+
"time-tracking",
|
|
21
|
+
"agents"
|
|
22
|
+
],
|
|
23
|
+
"files": [
|
|
24
|
+
"src/",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"pi": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./src/extension.ts"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --test tests/*.test.ts",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"check": "npm run typecheck && npm test",
|
|
37
|
+
"prepublishOnly": "npm run check"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
41
|
+
"@earendil-works/pi-tui": "^0.85.1",
|
|
42
|
+
"@types/node": "^24.13.4",
|
|
43
|
+
"typescript": "^5.7.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { WorkLog } from "../ports/work-log.ts";
|
|
4
|
+
import type { WorkRecord } from "../domain/work-record.ts";
|
|
5
|
+
|
|
6
|
+
const LOG_FILE_NAME = "worklog.jsonl";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Append-only JSONL {@link WorkLog} backed by `<dir>/worklog.jsonl`.
|
|
10
|
+
* A record is written with a single `appendFileSync` call so a parent and
|
|
11
|
+
* its subagent children can write concurrently without interleaving lines.
|
|
12
|
+
*/
|
|
13
|
+
export class JsonlWorkLog implements WorkLog {
|
|
14
|
+
private readonly dir: string;
|
|
15
|
+
|
|
16
|
+
constructor(dir: string) {
|
|
17
|
+
this.dir = dir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private get filePath(): string {
|
|
21
|
+
return join(this.dir, LOG_FILE_NAME);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
append(record: WorkRecord): void {
|
|
25
|
+
mkdirSync(this.dir, { recursive: true });
|
|
26
|
+
appendFileSync(this.filePath, `${JSON.stringify(record)}\n`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
readAll(): WorkRecord[] {
|
|
30
|
+
if (!existsSync(this.filePath)) return [];
|
|
31
|
+
const content = readFileSync(this.filePath, "utf8");
|
|
32
|
+
const records: WorkRecord[] = [];
|
|
33
|
+
for (const line of content.split("\n")) {
|
|
34
|
+
const trimmed = line.trim();
|
|
35
|
+
if (!trimmed) continue;
|
|
36
|
+
try {
|
|
37
|
+
records.push(JSON.parse(trimmed) as WorkRecord);
|
|
38
|
+
} catch {
|
|
39
|
+
// Tolerate malformed lines (e.g. a torn write); skip them.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return records;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { isAbsolute, join } from "node:path";
|
|
2
|
+
import type { WorkRecord } from "../domain/work-record.ts";
|
|
3
|
+
import type { WorkLog } from "../ports/work-log.ts";
|
|
4
|
+
import { JsonlWorkLog } from "./jsonl-work-log.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* {@link WorkLog} that resolves a relative log directory lazily: against the
|
|
8
|
+
* project of the first appended record, or against `fallbackCwd()` when a
|
|
9
|
+
* read happens before any record was written in this process.
|
|
10
|
+
*/
|
|
11
|
+
export class LazyJsonlWorkLog implements WorkLog {
|
|
12
|
+
private readonly dirOrRelative: string;
|
|
13
|
+
private readonly fallbackCwd: () => string;
|
|
14
|
+
private resolved: JsonlWorkLog | undefined;
|
|
15
|
+
|
|
16
|
+
constructor(dirOrRelative: string, fallbackCwd: () => string = () => process.cwd()) {
|
|
17
|
+
this.dirOrRelative = dirOrRelative;
|
|
18
|
+
this.fallbackCwd = fallbackCwd;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private resolveFor(cwd: string): JsonlWorkLog {
|
|
22
|
+
if (!this.resolved) {
|
|
23
|
+
const dir = isAbsolute(this.dirOrRelative) ? this.dirOrRelative : join(cwd, this.dirOrRelative);
|
|
24
|
+
this.resolved = new JsonlWorkLog(dir);
|
|
25
|
+
}
|
|
26
|
+
return this.resolved;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
append(record: WorkRecord): void {
|
|
30
|
+
this.resolveFor(record.project).append(record);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
readAll(): WorkRecord[] {
|
|
34
|
+
return this.resolveFor(this.fallbackCwd()).readAll();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { WorkTracker } from "../domain/work-tracker.ts";
|
|
4
|
+
import { buildSessions, buildTasks } from "../domain/task-view.ts";
|
|
5
|
+
import type { WorkRecord, WorkRole } from "../domain/work-record.ts";
|
|
6
|
+
import type { WorkLog } from "../ports/work-log.ts";
|
|
7
|
+
import { formatReport, formatSessions, formatTasks, localDay, summarize } from "./report.ts";
|
|
8
|
+
|
|
9
|
+
export interface PiTrackerDeps {
|
|
10
|
+
tracker: WorkTracker;
|
|
11
|
+
log: WorkLog;
|
|
12
|
+
role: WorkRole;
|
|
13
|
+
pid: number;
|
|
14
|
+
parentPid: number;
|
|
15
|
+
/** Status line refresh interval in ms. Defaults to 1000. */
|
|
16
|
+
statusIntervalMs?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const STATUS_KEY = "kankaku";
|
|
20
|
+
const REPORT_ENTRY_TYPE = "kankaku-report";
|
|
21
|
+
|
|
22
|
+
/** Durable report rendered inside the chat transcript; never sent to the LLM. */
|
|
23
|
+
export interface KankakuReportData {
|
|
24
|
+
title: string;
|
|
25
|
+
lines: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatElapsed(ms: number): string {
|
|
29
|
+
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
|
30
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
31
|
+
const seconds = totalSeconds % 60;
|
|
32
|
+
return `⏱ ${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function notifyError(ctx: ExtensionContext, error: unknown): void {
|
|
36
|
+
if (!ctx.hasUI) return;
|
|
37
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
38
|
+
ctx.ui.notify(`kankaku: ${message}`, "error");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Wraps a handler so it never throws out of the pi event loop. */
|
|
42
|
+
function guarded<E>(fn: (event: E, ctx: ExtensionContext) => void): (event: E, ctx: ExtensionContext) => void {
|
|
43
|
+
return (event, ctx) => {
|
|
44
|
+
try {
|
|
45
|
+
fn(event, ctx);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
notifyError(ctx, error);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Wires pi lifecycle events to a {@link WorkTracker}, persisting finished
|
|
54
|
+
* records to a {@link WorkLog} and exposing the `/kankaku` report command.
|
|
55
|
+
*/
|
|
56
|
+
export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
|
|
57
|
+
const { tracker, log, role, pid, parentPid } = deps;
|
|
58
|
+
const statusIntervalMs = deps.statusIntervalMs ?? 1000;
|
|
59
|
+
|
|
60
|
+
let runStartedAt: number | undefined;
|
|
61
|
+
let statusTimer: NodeJS.Timeout | undefined;
|
|
62
|
+
|
|
63
|
+
function stopStatus(ctx: ExtensionContext): void {
|
|
64
|
+
if (statusTimer) {
|
|
65
|
+
clearInterval(statusTimer);
|
|
66
|
+
statusTimer = undefined;
|
|
67
|
+
}
|
|
68
|
+
if (ctx.hasUI) {
|
|
69
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
70
|
+
}
|
|
71
|
+
runStartedAt = undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function startStatus(ctx: ExtensionContext): void {
|
|
75
|
+
if (!ctx.hasUI) return;
|
|
76
|
+
runStartedAt = Date.now();
|
|
77
|
+
ctx.ui.setStatus(STATUS_KEY, formatElapsed(0));
|
|
78
|
+
statusTimer = setInterval(() => {
|
|
79
|
+
if (runStartedAt === undefined) return;
|
|
80
|
+
ctx.ui.setStatus(STATUS_KEY, formatElapsed(Date.now() - runStartedAt));
|
|
81
|
+
}, statusIntervalMs);
|
|
82
|
+
statusTimer.unref?.();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildRecord(core: NonNullable<ReturnType<WorkTracker["onSettled"]>>, ctx: ExtensionContext): WorkRecord {
|
|
86
|
+
const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
87
|
+
return {
|
|
88
|
+
...core,
|
|
89
|
+
role,
|
|
90
|
+
pid,
|
|
91
|
+
parentPid,
|
|
92
|
+
project: ctx.cwd,
|
|
93
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
94
|
+
sessionFile: ctx.sessionManager.getSessionFile(),
|
|
95
|
+
mode: ctx.mode,
|
|
96
|
+
...(model !== undefined ? { model } : {}),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
pi.on(
|
|
101
|
+
"before_agent_start",
|
|
102
|
+
guarded((event, ctx) => {
|
|
103
|
+
tracker.onRunStart(event.prompt);
|
|
104
|
+
if (runStartedAt === undefined) startStatus(ctx);
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
pi.on(
|
|
109
|
+
"agent_end",
|
|
110
|
+
guarded((event) => {
|
|
111
|
+
tracker.onRunEnd(event.messages);
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
pi.on(
|
|
116
|
+
"turn_end",
|
|
117
|
+
guarded((event) => {
|
|
118
|
+
const message = event.message;
|
|
119
|
+
const usage = message && "usage" in message ? message.usage : undefined;
|
|
120
|
+
tracker.onTurnEnd(
|
|
121
|
+
usage
|
|
122
|
+
? {
|
|
123
|
+
input: usage.input,
|
|
124
|
+
output: usage.output,
|
|
125
|
+
cacheRead: usage.cacheRead,
|
|
126
|
+
cacheWrite: usage.cacheWrite,
|
|
127
|
+
cost: usage.cost.total,
|
|
128
|
+
}
|
|
129
|
+
: undefined,
|
|
130
|
+
);
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
pi.on(
|
|
135
|
+
"tool_execution_start",
|
|
136
|
+
guarded((event) => {
|
|
137
|
+
tracker.onToolStart(event.toolCallId, event.toolName, event.args);
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
pi.on(
|
|
142
|
+
"tool_execution_end",
|
|
143
|
+
guarded((event) => {
|
|
144
|
+
tracker.onToolEnd(event.toolCallId, event.result);
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
pi.on(
|
|
149
|
+
"ui_prompt_start",
|
|
150
|
+
guarded((event) => {
|
|
151
|
+
tracker.onUiPromptStart(event.kind);
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
pi.on(
|
|
156
|
+
"ui_prompt_end",
|
|
157
|
+
guarded((event) => {
|
|
158
|
+
tracker.onUiPromptEnd(event.kind);
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
pi.on(
|
|
163
|
+
"agent_settled",
|
|
164
|
+
guarded((_event, ctx) => {
|
|
165
|
+
const core = tracker.onSettled();
|
|
166
|
+
if (core) {
|
|
167
|
+
log.append(buildRecord(core, ctx));
|
|
168
|
+
}
|
|
169
|
+
stopStatus(ctx);
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
pi.on(
|
|
174
|
+
"session_shutdown",
|
|
175
|
+
guarded((_event, ctx) => {
|
|
176
|
+
const core = tracker.onShutdown();
|
|
177
|
+
if (core) {
|
|
178
|
+
log.append(buildRecord(core, ctx));
|
|
179
|
+
}
|
|
180
|
+
stopStatus(ctx);
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
pi.registerEntryRenderer<KankakuReportData>(REPORT_ENTRY_TYPE, (entry, _options, theme) => {
|
|
185
|
+
const data = entry.data ?? { title: "kankaku", lines: [] };
|
|
186
|
+
const box = new Box(1, 0, (text) => theme.bg("customMessageBg", text));
|
|
187
|
+
box.addChild(new Text(`${theme.fg("accent", "kankaku")} ${data.title}`, 0, 0));
|
|
188
|
+
for (const line of data.lines) {
|
|
189
|
+
box.addChild(new Text(line, 0, 0));
|
|
190
|
+
}
|
|
191
|
+
return box;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
function showReport(ctx: ExtensionContext, report: KankakuReportData): void {
|
|
195
|
+
if (ctx.hasUI) {
|
|
196
|
+
pi.appendEntry<KankakuReportData>(REPORT_ENTRY_TYPE, report);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
ctx.ui.notify(`${report.title}\n${report.lines.join("\n")}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
pi.registerCommand("kankaku", {
|
|
203
|
+
description:
|
|
204
|
+
"Show kankaku work-time totals for today. Args (any order): 'all' for every record, " +
|
|
205
|
+
"'tasks' for this session's tasks ('tasks all' for every session), 'sessions' for today's sessions.",
|
|
206
|
+
handler: async (args, ctx) => {
|
|
207
|
+
try {
|
|
208
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
209
|
+
const all = tokens.includes("all");
|
|
210
|
+
const records = log.readAll();
|
|
211
|
+
const today = localDay(new Date().toISOString());
|
|
212
|
+
|
|
213
|
+
if (tokens.includes("tasks")) {
|
|
214
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
215
|
+
const scoped = all || !sessionId;
|
|
216
|
+
const tasks = buildTasks(records).filter((task) => scoped || task.sessionId === sessionId);
|
|
217
|
+
showReport(ctx, {
|
|
218
|
+
title: scoped ? "tasks (every session)" : "tasks (this session)",
|
|
219
|
+
lines: formatTasks(tasks).split("\n"),
|
|
220
|
+
});
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (tokens.includes("sessions")) {
|
|
225
|
+
const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
|
|
226
|
+
showReport(ctx, {
|
|
227
|
+
title: all ? "sessions (all days)" : "sessions (today)",
|
|
228
|
+
lines: formatSessions(buildSessions(tasks)).split("\n"),
|
|
229
|
+
});
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const summary = summarize(records, { all });
|
|
234
|
+
showReport(ctx, {
|
|
235
|
+
title: all ? "summary (all days)" : "summary (today)",
|
|
236
|
+
lines: formatReport(summary).split(" | "),
|
|
237
|
+
});
|
|
238
|
+
} catch (error) {
|
|
239
|
+
notifyError(ctx, error);
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { buildTasks } from "../domain/task-view.ts";
|
|
2
|
+
import type { SessionView, TaskView } from "../domain/task-view.ts";
|
|
3
|
+
import type { WorkRecord, WorkRole } from "../domain/work-record.ts";
|
|
4
|
+
|
|
5
|
+
export interface RoleTotals {
|
|
6
|
+
workMs: number;
|
|
7
|
+
waitingMs: number;
|
|
8
|
+
wallMs: number;
|
|
9
|
+
count: number;
|
|
10
|
+
/** Estimated cost in USD, as priced by pi's model table. */
|
|
11
|
+
cost: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TaskTotals {
|
|
15
|
+
count: number;
|
|
16
|
+
wallMs: number;
|
|
17
|
+
workMs: number;
|
|
18
|
+
/** Estimated cost in USD, orchestrator and subagents combined. */
|
|
19
|
+
cost: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type Summary = Record<WorkRole, RoleTotals> & { tasks: TaskTotals };
|
|
23
|
+
|
|
24
|
+
export interface SummarizeOptions {
|
|
25
|
+
/** Local day in `YYYY-MM-DD` format. Defaults to today when `all` is not set. */
|
|
26
|
+
day?: string;
|
|
27
|
+
/** Include every record regardless of day. */
|
|
28
|
+
all?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ROLES: WorkRole[] = ["orchestrator", "subagent"];
|
|
32
|
+
|
|
33
|
+
/** Local (not UTC) calendar day of an ISO timestamp, as `YYYY-MM-DD`. */
|
|
34
|
+
export function localDay(iso: string): string {
|
|
35
|
+
const date = new Date(iso);
|
|
36
|
+
const year = date.getFullYear();
|
|
37
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
38
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
39
|
+
return `${year}-${month}-${day}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function emptyTotals(): RoleTotals {
|
|
43
|
+
return { workMs: 0, waitingMs: 0, wallMs: 0, count: 0, cost: 0 };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Aggregate work records by role, restricted to one local day unless `all`
|
|
48
|
+
* is set. Also computes a `tasks` segment (union-based `wallMs`/`workMs`
|
|
49
|
+
* over one orchestrator run and its subagents) for tasks whose `startedAt`
|
|
50
|
+
* falls on the same day.
|
|
51
|
+
*/
|
|
52
|
+
export function summarize(records: WorkRecord[], options: SummarizeOptions): Summary {
|
|
53
|
+
const targetDay = options.all ? undefined : (options.day ?? localDay(new Date().toISOString()));
|
|
54
|
+
|
|
55
|
+
const summary: Summary = {
|
|
56
|
+
orchestrator: emptyTotals(),
|
|
57
|
+
subagent: emptyTotals(),
|
|
58
|
+
tasks: { count: 0, wallMs: 0, workMs: 0, cost: 0 },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
for (const record of records) {
|
|
62
|
+
if (targetDay !== undefined && localDay(record.startedAt) !== targetDay) continue;
|
|
63
|
+
const totals = summary[record.role];
|
|
64
|
+
totals.workMs += record.workMs;
|
|
65
|
+
totals.waitingMs += record.waitingMs;
|
|
66
|
+
totals.wallMs += record.wallMs;
|
|
67
|
+
totals.count += 1;
|
|
68
|
+
totals.cost += record.usage.cost;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const tasks = buildTasks(records).filter((task) => targetDay === undefined || localDay(task.startedAt) === targetDay);
|
|
72
|
+
for (const task of tasks) {
|
|
73
|
+
summary.tasks.count += 1;
|
|
74
|
+
summary.tasks.wallMs += task.wallMs;
|
|
75
|
+
summary.tasks.workMs += task.workMs;
|
|
76
|
+
summary.tasks.cost += task.usage.cost;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return summary;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function formatMinutes(ms: number): string {
|
|
83
|
+
const totalSeconds = Math.round(ms / 1000);
|
|
84
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
85
|
+
const seconds = totalSeconds % 60;
|
|
86
|
+
return `${minutes}m${String(seconds).padStart(2, "0")}s`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Estimated USD cost with two decimals, e.g. `$1.23`. */
|
|
90
|
+
function formatCost(cost: number): string {
|
|
91
|
+
return `$${cost.toFixed(2)}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function formatTime(iso: string): string {
|
|
95
|
+
const date = new Date(iso);
|
|
96
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
97
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
98
|
+
return `${hours}:${minutes}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Render a short, human-readable summary for the `/kankaku` command. */
|
|
102
|
+
export function formatReport(summary: Summary): string {
|
|
103
|
+
const lines = ROLES.map((role) => {
|
|
104
|
+
const totals = summary[role];
|
|
105
|
+
return `${role}: work ${formatMinutes(totals.workMs)}, waiting ${formatMinutes(totals.waitingMs)}, ${totals.count} record(s), ${formatCost(totals.cost)}`;
|
|
106
|
+
});
|
|
107
|
+
lines.push(
|
|
108
|
+
`tasks: ${summary.tasks.count}, wall ${formatMinutes(summary.tasks.wallMs)}, work ${formatMinutes(summary.tasks.workMs)}, ${formatCost(summary.tasks.cost)}`,
|
|
109
|
+
);
|
|
110
|
+
return lines.join(" | ");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Render one line per task: time, union-based wall/work, cost, subagent count, and a truncated prompt. */
|
|
114
|
+
export function formatTasks(tasks: TaskView[]): string {
|
|
115
|
+
if (tasks.length === 0) return "no tasks";
|
|
116
|
+
return tasks
|
|
117
|
+
.map((task) => {
|
|
118
|
+
const prompt = task.prompt.length > 60 ? task.prompt.slice(0, 60) : task.prompt;
|
|
119
|
+
return `${formatTime(task.startedAt)} wall ${formatMinutes(task.wallMs)} work ${formatMinutes(task.workMs)} ${formatCost(task.usage.cost)} subagents ${task.subagents.length} ${prompt}`;
|
|
120
|
+
})
|
|
121
|
+
.join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Render one line per session: truncated id, time range, union-based wall/work, cost, and task count. */
|
|
125
|
+
export function formatSessions(sessions: SessionView[]): string {
|
|
126
|
+
if (sessions.length === 0) return "no sessions";
|
|
127
|
+
return sessions
|
|
128
|
+
.map(
|
|
129
|
+
(session) =>
|
|
130
|
+
`${session.sessionId.slice(0, 8)} ${formatTime(session.startedAt)}–${formatTime(session.endedAt)} wall ${formatMinutes(session.wallMs)} work ${formatMinutes(session.workMs)} ${formatCost(session.usage.cost)} tasks ${session.tasks.length}`,
|
|
131
|
+
)
|
|
132
|
+
.join("\n");
|
|
133
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface KankakuConfig {
|
|
2
|
+
/** Directory for the work log, relative to the project cwd unless absolute. */
|
|
3
|
+
dir: string;
|
|
4
|
+
/** Tool names whose execution span counts as waiting time. */
|
|
5
|
+
interactiveTools: string[];
|
|
6
|
+
/** Tool name used to run subagents. */
|
|
7
|
+
subagentTool: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DEFAULT_DIR = ".kankaku";
|
|
11
|
+
const DEFAULT_INTERACTIVE_TOOLS = ["ask_user_question", "ask_user_choice"];
|
|
12
|
+
const SUBAGENT_TOOL = "subagent_run";
|
|
13
|
+
|
|
14
|
+
export function loadConfig(env: NodeJS.ProcessEnv = process.env): KankakuConfig {
|
|
15
|
+
const dir = env["KANKAKU_DIR"]?.trim() || DEFAULT_DIR;
|
|
16
|
+
const interactiveToolsRaw = env["KANKAKU_INTERACTIVE_TOOLS"]?.trim();
|
|
17
|
+
const interactiveTools = interactiveToolsRaw
|
|
18
|
+
? interactiveToolsRaw
|
|
19
|
+
.split(",")
|
|
20
|
+
.map((tool) => tool.trim())
|
|
21
|
+
.filter((tool) => tool.length > 0)
|
|
22
|
+
: DEFAULT_INTERACTIVE_TOOLS;
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
dir,
|
|
26
|
+
interactiveTools,
|
|
27
|
+
subagentTool: SUBAGENT_TOOL,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function detectRole(env: NodeJS.ProcessEnv = process.env): "orchestrator" | "subagent" {
|
|
32
|
+
return env["GENTLE_PI_AGENTS_CHILD"] === "1" ? "subagent" : "orchestrator";
|
|
33
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export interface Interval {
|
|
2
|
+
start: number;
|
|
3
|
+
end: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Restrict every interval to `[windowStart, windowEnd]` and drop any that
|
|
8
|
+
* become empty (or invalid) after clamping.
|
|
9
|
+
*/
|
|
10
|
+
export function clampIntervals(intervals: Interval[], windowStart: number, windowEnd: number): Interval[] {
|
|
11
|
+
return intervals
|
|
12
|
+
.map((interval) => ({
|
|
13
|
+
start: Math.max(interval.start, windowStart),
|
|
14
|
+
end: Math.min(interval.end, windowEnd),
|
|
15
|
+
}))
|
|
16
|
+
.filter((interval) => interval.end > interval.start);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Total duration covered by the union of the given intervals — never the
|
|
21
|
+
* sum, so overlapping (e.g. parallel subagent) spans are not double-counted.
|
|
22
|
+
*/
|
|
23
|
+
export function unionMs(intervals: Array<{ start: number; end: number }>): number {
|
|
24
|
+
const sorted = [...intervals].sort((a, b) => a.start - b.start);
|
|
25
|
+
|
|
26
|
+
let total = 0;
|
|
27
|
+
let currentStart: number | undefined;
|
|
28
|
+
let currentEnd: number | undefined;
|
|
29
|
+
|
|
30
|
+
for (const interval of sorted) {
|
|
31
|
+
if (interval.end <= interval.start) continue;
|
|
32
|
+
|
|
33
|
+
if (currentStart === undefined || currentEnd === undefined) {
|
|
34
|
+
currentStart = interval.start;
|
|
35
|
+
currentEnd = interval.end;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (interval.start <= currentEnd) {
|
|
40
|
+
currentEnd = Math.max(currentEnd, interval.end);
|
|
41
|
+
} else {
|
|
42
|
+
total += currentEnd - currentStart;
|
|
43
|
+
currentStart = interval.start;
|
|
44
|
+
currentEnd = interval.end;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (currentStart !== undefined && currentEnd !== undefined) {
|
|
49
|
+
total += currentEnd - currentStart;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return total;
|
|
53
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { unionMs } from "./intervals.ts";
|
|
2
|
+
import { emptyUsage } from "./work-record.ts";
|
|
3
|
+
import type { UsageTotals, WorkRecord, WorkStatus } from "./work-record.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One orchestrator run plus every subagent it spawned, with `wallMs`
|
|
7
|
+
* recomputed as the union of the orchestrator's span and each child's span
|
|
8
|
+
* — never their sum — because background children keep running in parallel
|
|
9
|
+
* after the orchestrator settles.
|
|
10
|
+
*/
|
|
11
|
+
export interface TaskView {
|
|
12
|
+
id: string;
|
|
13
|
+
sessionId?: string;
|
|
14
|
+
project: string;
|
|
15
|
+
prompt: string;
|
|
16
|
+
startedAt: string;
|
|
17
|
+
endedAt: string;
|
|
18
|
+
wallMs: number;
|
|
19
|
+
waitingMs: number;
|
|
20
|
+
workMs: number;
|
|
21
|
+
status: WorkStatus;
|
|
22
|
+
orchestrator: WorkRecord;
|
|
23
|
+
subagents: WorkRecord[];
|
|
24
|
+
usage: UsageTotals;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One or more tasks grouped by their pi session, with the same union rule. */
|
|
28
|
+
export interface SessionView {
|
|
29
|
+
sessionId: string;
|
|
30
|
+
project: string;
|
|
31
|
+
startedAt: string;
|
|
32
|
+
endedAt: string;
|
|
33
|
+
wallMs: number;
|
|
34
|
+
waitingMs: number;
|
|
35
|
+
workMs: number;
|
|
36
|
+
tasks: TaskView[];
|
|
37
|
+
usage: UsageTotals;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toMs(iso: string): number {
|
|
41
|
+
return Date.parse(iso);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sumUsage(totals: UsageTotals[]): UsageTotals {
|
|
45
|
+
const usage = emptyUsage();
|
|
46
|
+
for (const total of totals) {
|
|
47
|
+
usage.input += total.input;
|
|
48
|
+
usage.output += total.output;
|
|
49
|
+
usage.cacheRead += total.cacheRead;
|
|
50
|
+
usage.cacheWrite += total.cacheWrite;
|
|
51
|
+
usage.cost += total.cost;
|
|
52
|
+
}
|
|
53
|
+
return usage;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Match every subagent record to the orchestrator record it belongs to: same
|
|
58
|
+
* project, `parentPid === orchestrator.pid`, and the child's `startedAt`
|
|
59
|
+
* falls inside the orchestrator's `[startedAt, settledAt]` window. When
|
|
60
|
+
* several orchestrator records match (a reused pid), the latest-starting one
|
|
61
|
+
* wins. Each child is assigned at most once; unmatched children are orphans.
|
|
62
|
+
*/
|
|
63
|
+
function matchChildren(records: WorkRecord[]): {
|
|
64
|
+
childrenByOrchestratorId: Map<string, WorkRecord[]>;
|
|
65
|
+
orphans: WorkRecord[];
|
|
66
|
+
} {
|
|
67
|
+
const orchestrators = records.filter((record) => record.role === "orchestrator");
|
|
68
|
+
const subagents = records.filter((record) => record.role === "subagent");
|
|
69
|
+
|
|
70
|
+
const childrenByOrchestratorId = new Map<string, WorkRecord[]>();
|
|
71
|
+
for (const orchestrator of orchestrators) {
|
|
72
|
+
childrenByOrchestratorId.set(orchestrator.id, []);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const orphans: WorkRecord[] = [];
|
|
76
|
+
|
|
77
|
+
for (const child of subagents) {
|
|
78
|
+
const childStart = toMs(child.startedAt);
|
|
79
|
+
let best: WorkRecord | undefined;
|
|
80
|
+
let bestStart = Number.NEGATIVE_INFINITY;
|
|
81
|
+
|
|
82
|
+
for (const orchestrator of orchestrators) {
|
|
83
|
+
if (orchestrator.project !== child.project) continue;
|
|
84
|
+
if (orchestrator.pid !== child.parentPid) continue;
|
|
85
|
+
|
|
86
|
+
const parentStart = toMs(orchestrator.startedAt);
|
|
87
|
+
const parentEnd = toMs(orchestrator.settledAt);
|
|
88
|
+
if (childStart < parentStart || childStart > parentEnd) continue;
|
|
89
|
+
|
|
90
|
+
if (parentStart > bestStart) {
|
|
91
|
+
bestStart = parentStart;
|
|
92
|
+
best = orchestrator;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (best) {
|
|
97
|
+
childrenByOrchestratorId.get(best.id)!.push(child);
|
|
98
|
+
} else {
|
|
99
|
+
orphans.push(child);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { childrenByOrchestratorId, orphans };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildTaskView(orchestrator: WorkRecord, subagents: WorkRecord[]): TaskView {
|
|
107
|
+
const parentStart = toMs(orchestrator.startedAt);
|
|
108
|
+
const parentEnd = toMs(orchestrator.settledAt);
|
|
109
|
+
|
|
110
|
+
const intervals = [
|
|
111
|
+
{ start: parentStart, end: parentEnd },
|
|
112
|
+
...subagents.map((child) => ({ start: toMs(child.startedAt), end: toMs(child.settledAt) })),
|
|
113
|
+
];
|
|
114
|
+
const wallMs = unionMs(intervals);
|
|
115
|
+
|
|
116
|
+
const endedAtMs = Math.max(parentEnd, ...subagents.map((child) => toMs(child.settledAt)));
|
|
117
|
+
const waitingMs = orchestrator.waitingMs;
|
|
118
|
+
const workMs = wallMs - waitingMs;
|
|
119
|
+
const usage = sumUsage([orchestrator.usage, ...subagents.map((child) => child.usage)]);
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
id: orchestrator.id,
|
|
123
|
+
...(orchestrator.sessionId !== undefined ? { sessionId: orchestrator.sessionId } : {}),
|
|
124
|
+
project: orchestrator.project,
|
|
125
|
+
prompt: orchestrator.prompt,
|
|
126
|
+
startedAt: orchestrator.startedAt,
|
|
127
|
+
endedAt: new Date(endedAtMs).toISOString(),
|
|
128
|
+
wallMs,
|
|
129
|
+
waitingMs,
|
|
130
|
+
workMs,
|
|
131
|
+
status: orchestrator.status,
|
|
132
|
+
orchestrator,
|
|
133
|
+
subagents,
|
|
134
|
+
usage,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Build one {@link TaskView} per orchestrator record, sorted by `startedAt`. */
|
|
139
|
+
export function buildTasks(records: WorkRecord[]): TaskView[] {
|
|
140
|
+
const orchestrators = records
|
|
141
|
+
.filter((record) => record.role === "orchestrator")
|
|
142
|
+
.sort((a, b) => toMs(a.startedAt) - toMs(b.startedAt));
|
|
143
|
+
|
|
144
|
+
const { childrenByOrchestratorId } = matchChildren(records);
|
|
145
|
+
|
|
146
|
+
return orchestrators.map((orchestrator) => buildTaskView(orchestrator, childrenByOrchestratorId.get(orchestrator.id) ?? []));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Subagent records that could not be matched to any orchestrator record. */
|
|
150
|
+
export function orphanSubagents(records: WorkRecord[]): WorkRecord[] {
|
|
151
|
+
return matchChildren(records).orphans;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Group tasks by `sessionId` (tasks without one fall under `"unknown"`).
|
|
156
|
+
* `wallMs` is the union of every interval — orchestrator and subagent alike
|
|
157
|
+
* — across all of the session's tasks, not a sum of per-task `wallMs`.
|
|
158
|
+
*/
|
|
159
|
+
export function buildSessions(tasks: TaskView[]): SessionView[] {
|
|
160
|
+
const groups = new Map<string, TaskView[]>();
|
|
161
|
+
for (const task of tasks) {
|
|
162
|
+
const key = task.sessionId ?? "unknown";
|
|
163
|
+
const list = groups.get(key);
|
|
164
|
+
if (list) {
|
|
165
|
+
list.push(task);
|
|
166
|
+
} else {
|
|
167
|
+
groups.set(key, [task]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const sessions: SessionView[] = [];
|
|
172
|
+
|
|
173
|
+
for (const [sessionId, sessionTasks] of groups) {
|
|
174
|
+
const intervals = sessionTasks.flatMap((task) => [
|
|
175
|
+
{ start: toMs(task.orchestrator.startedAt), end: toMs(task.orchestrator.settledAt) },
|
|
176
|
+
...task.subagents.map((child) => ({ start: toMs(child.startedAt), end: toMs(child.settledAt) })),
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
const wallMs = unionMs(intervals);
|
|
180
|
+
const waitingMs = sessionTasks.reduce((sum, task) => sum + task.waitingMs, 0);
|
|
181
|
+
const workMs = wallMs - waitingMs;
|
|
182
|
+
const startedAtMs = Math.min(...sessionTasks.map((task) => toMs(task.startedAt)));
|
|
183
|
+
const endedAtMs = Math.max(...sessionTasks.map((task) => toMs(task.endedAt)));
|
|
184
|
+
const usage = sumUsage(sessionTasks.map((task) => task.usage));
|
|
185
|
+
|
|
186
|
+
sessions.push({
|
|
187
|
+
sessionId,
|
|
188
|
+
project: sessionTasks[0]!.project,
|
|
189
|
+
startedAt: new Date(startedAtMs).toISOString(),
|
|
190
|
+
endedAt: new Date(endedAtMs).toISOString(),
|
|
191
|
+
wallMs,
|
|
192
|
+
waitingMs,
|
|
193
|
+
workMs,
|
|
194
|
+
tasks: sessionTasks,
|
|
195
|
+
usage,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return sessions.sort((a, b) => toMs(a.startedAt) - toMs(b.startedAt));
|
|
200
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Current schema version for {@link WorkRecord}. */
|
|
2
|
+
export const WORK_RECORD_SCHEMA = 1;
|
|
3
|
+
|
|
4
|
+
export type WorkRole = "orchestrator" | "subagent";
|
|
5
|
+
|
|
6
|
+
export type WorkStatus = "completed" | "aborted" | "interrupted";
|
|
7
|
+
|
|
8
|
+
export interface UsageTotals {
|
|
9
|
+
input: number;
|
|
10
|
+
output: number;
|
|
11
|
+
cacheRead: number;
|
|
12
|
+
cacheWrite: number;
|
|
13
|
+
cost: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SubagentSpan {
|
|
17
|
+
toolCallId: string;
|
|
18
|
+
agent: string;
|
|
19
|
+
mode: string;
|
|
20
|
+
taskId?: string;
|
|
21
|
+
ms: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fields the pure {@link WorkTracker} state machine can compute on its own,
|
|
26
|
+
* with no knowledge of the pi process or session it runs in.
|
|
27
|
+
*/
|
|
28
|
+
export interface WorkRecordCore {
|
|
29
|
+
schema: number;
|
|
30
|
+
id: string;
|
|
31
|
+
prompt: string;
|
|
32
|
+
startedAt: string;
|
|
33
|
+
settledAt: string;
|
|
34
|
+
wallMs: number;
|
|
35
|
+
waitingMs: number;
|
|
36
|
+
workMs: number;
|
|
37
|
+
runs: number;
|
|
38
|
+
turns: number;
|
|
39
|
+
tools: Record<string, number>;
|
|
40
|
+
subagents: SubagentSpan[];
|
|
41
|
+
usage: UsageTotals;
|
|
42
|
+
status: WorkStatus;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Process/session metadata the adapter layer attaches before persisting a record. */
|
|
46
|
+
export interface WorkRecordMetadata {
|
|
47
|
+
role: WorkRole;
|
|
48
|
+
pid: number;
|
|
49
|
+
parentPid: number;
|
|
50
|
+
project: string;
|
|
51
|
+
sessionId?: string;
|
|
52
|
+
sessionFile?: string;
|
|
53
|
+
mode?: string;
|
|
54
|
+
model?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type WorkRecord = WorkRecordCore & WorkRecordMetadata;
|
|
58
|
+
|
|
59
|
+
export function emptyUsage(): UsageTotals {
|
|
60
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
61
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { Clock } from "../ports/clock.ts";
|
|
3
|
+
import { clampIntervals, unionMs } from "./intervals.ts";
|
|
4
|
+
import { emptyUsage, WORK_RECORD_SCHEMA } from "./work-record.ts";
|
|
5
|
+
import type { SubagentSpan, UsageTotals, WorkRecordCore, WorkStatus } from "./work-record.ts";
|
|
6
|
+
|
|
7
|
+
export interface WorkTrackerOptions {
|
|
8
|
+
clock: Clock;
|
|
9
|
+
interactiveTools: string[];
|
|
10
|
+
subagentTool: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Interval {
|
|
14
|
+
start: number;
|
|
15
|
+
end: number | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface OpenSubagentSpan {
|
|
19
|
+
toolCallId: string;
|
|
20
|
+
agent: string;
|
|
21
|
+
mode: string;
|
|
22
|
+
start: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface RunState {
|
|
26
|
+
startedAt: number;
|
|
27
|
+
prompt: string;
|
|
28
|
+
runs: number;
|
|
29
|
+
turns: number;
|
|
30
|
+
tools: Record<string, number>;
|
|
31
|
+
usage: UsageTotals;
|
|
32
|
+
status: WorkStatus;
|
|
33
|
+
waitingSpans: Interval[];
|
|
34
|
+
/** Waiting spans opened by interactive tools, keyed by tool call id. */
|
|
35
|
+
openToolWaits: Map<string, Interval>;
|
|
36
|
+
subagents: SubagentSpan[];
|
|
37
|
+
openSubagents: Map<string, OpenSubagentSpan>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface RunEndMessage {
|
|
41
|
+
role: string;
|
|
42
|
+
stopReason?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pure domain state machine that turns pi lifecycle events into finished
|
|
47
|
+
* {@link WorkRecord} entries. Holds no I/O; timestamps come from the
|
|
48
|
+
* injected {@link Clock} so behaviour is deterministic under test.
|
|
49
|
+
*/
|
|
50
|
+
export class WorkTracker {
|
|
51
|
+
private readonly clock: Clock;
|
|
52
|
+
private readonly interactiveTools: Set<string>;
|
|
53
|
+
private readonly subagentTool: string;
|
|
54
|
+
private state: RunState | undefined;
|
|
55
|
+
|
|
56
|
+
constructor(options: WorkTrackerOptions) {
|
|
57
|
+
this.clock = options.clock;
|
|
58
|
+
this.interactiveTools = new Set(options.interactiveTools);
|
|
59
|
+
this.subagentTool = options.subagentTool;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
onRunStart(prompt: string): void {
|
|
63
|
+
if (!this.state) {
|
|
64
|
+
this.state = {
|
|
65
|
+
startedAt: this.clock.now(),
|
|
66
|
+
prompt,
|
|
67
|
+
runs: 1,
|
|
68
|
+
turns: 0,
|
|
69
|
+
tools: {},
|
|
70
|
+
usage: emptyUsage(),
|
|
71
|
+
status: "completed",
|
|
72
|
+
waitingSpans: [],
|
|
73
|
+
openToolWaits: new Map(),
|
|
74
|
+
subagents: [],
|
|
75
|
+
openSubagents: new Map(),
|
|
76
|
+
};
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
this.state.runs++;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
onTurnEnd(usage: Partial<UsageTotals> | undefined): void {
|
|
83
|
+
if (!this.state) return;
|
|
84
|
+
this.state.turns++;
|
|
85
|
+
if (!usage) return;
|
|
86
|
+
this.state.usage.input += usage.input ?? 0;
|
|
87
|
+
this.state.usage.output += usage.output ?? 0;
|
|
88
|
+
this.state.usage.cacheRead += usage.cacheRead ?? 0;
|
|
89
|
+
this.state.usage.cacheWrite += usage.cacheWrite ?? 0;
|
|
90
|
+
this.state.usage.cost += usage.cost ?? 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
onToolStart(toolCallId: string, toolName: string, args: Record<string, unknown> | undefined): void {
|
|
94
|
+
if (!this.state) return;
|
|
95
|
+
this.state.tools[toolName] = (this.state.tools[toolName] ?? 0) + 1;
|
|
96
|
+
|
|
97
|
+
if (this.interactiveTools.has(toolName)) {
|
|
98
|
+
const span: Interval = { start: this.clock.now(), end: undefined };
|
|
99
|
+
this.state.waitingSpans.push(span);
|
|
100
|
+
this.state.openToolWaits.set(toolCallId, span);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (toolName === this.subagentTool) {
|
|
105
|
+
const agent = typeof args?.["agent"] === "string" ? (args["agent"] as string) : "unknown";
|
|
106
|
+
const mode = typeof args?.["mode"] === "string" ? (args["mode"] as string) : "task";
|
|
107
|
+
this.state.openSubagents.set(toolCallId, {
|
|
108
|
+
toolCallId,
|
|
109
|
+
agent,
|
|
110
|
+
mode,
|
|
111
|
+
start: this.clock.now(),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
onToolEnd(toolCallId: string, result: unknown): void {
|
|
117
|
+
if (!this.state) return;
|
|
118
|
+
|
|
119
|
+
const openSubagent = this.state.openSubagents.get(toolCallId);
|
|
120
|
+
if (openSubagent) {
|
|
121
|
+
this.state.openSubagents.delete(toolCallId);
|
|
122
|
+
const taskId = extractTaskId(result);
|
|
123
|
+
this.state.subagents.push({
|
|
124
|
+
toolCallId: openSubagent.toolCallId,
|
|
125
|
+
agent: openSubagent.agent,
|
|
126
|
+
mode: openSubagent.mode,
|
|
127
|
+
...(taskId !== undefined ? { taskId } : {}),
|
|
128
|
+
ms: this.clock.now() - openSubagent.start,
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const openWaitingSpan = this.state.openToolWaits.get(toolCallId);
|
|
134
|
+
if (openWaitingSpan) {
|
|
135
|
+
this.state.openToolWaits.delete(toolCallId);
|
|
136
|
+
openWaitingSpan.end = this.clock.now();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
onUiPromptStart(_kind: string): void {
|
|
141
|
+
if (!this.state) return;
|
|
142
|
+
this.state.waitingSpans.push({ start: this.clock.now(), end: undefined });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
onUiPromptEnd(_kind: string): void {
|
|
146
|
+
if (!this.state) return;
|
|
147
|
+
// Prompts are sequential; close the most recently opened span (LIFO).
|
|
148
|
+
for (let i = this.state.waitingSpans.length - 1; i >= 0; i--) {
|
|
149
|
+
const span = this.state.waitingSpans[i];
|
|
150
|
+
if (span && span.end === undefined) {
|
|
151
|
+
span.end = this.clock.now();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
onRunEnd(messages: RunEndMessage[]): void {
|
|
158
|
+
if (!this.state) return;
|
|
159
|
+
const lastAssistant = [...messages].reverse().find((message) => message.role === "assistant");
|
|
160
|
+
if (lastAssistant?.stopReason === "aborted") {
|
|
161
|
+
this.state.status = "aborted";
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
onSettled(): WorkRecordCore | undefined {
|
|
166
|
+
if (!this.state) return undefined;
|
|
167
|
+
const record = this.finalize(this.state.status);
|
|
168
|
+
this.state = undefined;
|
|
169
|
+
return record;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
onShutdown(): WorkRecordCore | undefined {
|
|
173
|
+
if (!this.state) return undefined;
|
|
174
|
+
const record = this.finalize("interrupted");
|
|
175
|
+
this.state = undefined;
|
|
176
|
+
return record;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private finalize(status: WorkStatus): WorkRecordCore {
|
|
180
|
+
const state = this.state;
|
|
181
|
+
if (!state) {
|
|
182
|
+
throw new Error("finalize called without an open run");
|
|
183
|
+
}
|
|
184
|
+
const settledAt = this.clock.now();
|
|
185
|
+
const wallMs = settledAt - state.startedAt;
|
|
186
|
+
|
|
187
|
+
for (const span of state.waitingSpans) {
|
|
188
|
+
if (span.end === undefined) {
|
|
189
|
+
span.end = settledAt;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const closedSpans = state.waitingSpans.map((span) => ({ start: span.start, end: span.end as number }));
|
|
194
|
+
const waitingMs = unionMs(clampIntervals(closedSpans, state.startedAt, settledAt));
|
|
195
|
+
const workMs = wallMs - waitingMs;
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
schema: WORK_RECORD_SCHEMA,
|
|
199
|
+
id: randomUUID(),
|
|
200
|
+
prompt: state.prompt,
|
|
201
|
+
startedAt: new Date(state.startedAt).toISOString(),
|
|
202
|
+
settledAt: new Date(settledAt).toISOString(),
|
|
203
|
+
wallMs,
|
|
204
|
+
waitingMs,
|
|
205
|
+
workMs,
|
|
206
|
+
runs: state.runs,
|
|
207
|
+
turns: state.turns,
|
|
208
|
+
tools: state.tools,
|
|
209
|
+
subagents: state.subagents,
|
|
210
|
+
usage: state.usage,
|
|
211
|
+
status,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function extractTaskId(result: unknown): string | undefined {
|
|
217
|
+
if (!result || typeof result !== "object") return undefined;
|
|
218
|
+
const details = (result as { details?: unknown }).details;
|
|
219
|
+
if (!details || typeof details !== "object") return undefined;
|
|
220
|
+
const gentleAgents = (details as { gentleAgents?: unknown }).gentleAgents;
|
|
221
|
+
if (!gentleAgents || typeof gentleAgents !== "object") return undefined;
|
|
222
|
+
const taskId = (gentleAgents as { taskId?: unknown }).taskId;
|
|
223
|
+
return typeof taskId === "string" ? taskId : undefined;
|
|
224
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { detectRole, loadConfig } from "./config.ts";
|
|
3
|
+
import { WorkTracker } from "./domain/work-tracker.ts";
|
|
4
|
+
import { LazyJsonlWorkLog } from "./adapters/lazy-jsonl-work-log.ts";
|
|
5
|
+
import { createPiTracker } from "./adapters/pi-tracker.ts";
|
|
6
|
+
|
|
7
|
+
export default function kankaku(pi: ExtensionAPI): void {
|
|
8
|
+
const config = loadConfig();
|
|
9
|
+
const role = detectRole();
|
|
10
|
+
|
|
11
|
+
const tracker = new WorkTracker({
|
|
12
|
+
clock: { now: () => Date.now() },
|
|
13
|
+
interactiveTools: config.interactiveTools,
|
|
14
|
+
subagentTool: config.subagentTool,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const log = new LazyJsonlWorkLog(config.dir);
|
|
18
|
+
|
|
19
|
+
createPiTracker(pi, {
|
|
20
|
+
tracker,
|
|
21
|
+
log,
|
|
22
|
+
role,
|
|
23
|
+
pid: process.pid,
|
|
24
|
+
parentPid: process.ppid,
|
|
25
|
+
});
|
|
26
|
+
}
|