@chenlongapps/opencode-token-usage 0.4.8 → 0.4.10

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.
@@ -0,0 +1,155 @@
1
+ const timestamp = (value) => typeof value === "number" && value >= 0
2
+ && Number.isFinite(new Date(value).getTime());
3
+ /** Shared server-time anchor and display clock; no wall clock, execution log or periodic reads. */
4
+ export class RuntimeMonitor {
5
+ monotonic;
6
+ anchor;
7
+ latest = new Map();
8
+ stopped = new Set();
9
+ listeners = new Set();
10
+ ticks = new Set();
11
+ timer;
12
+ synchronization;
13
+ disposed = false;
14
+ unsubscribe;
15
+ constructor(subscribe, monotonic = () => performance.now()) {
16
+ this.monotonic = monotonic;
17
+ this.unsubscribe = subscribe?.(event => this.handle(event));
18
+ }
19
+ now() {
20
+ if (!this.anchor)
21
+ return undefined;
22
+ const now = this.anchor.server + Math.max(0, this.monotonic() - this.anchor.local);
23
+ return timestamp(now) ? now : undefined;
24
+ }
25
+ /** One optional read-only RPC supplies an anchor even when opening a quiet active tool. */
26
+ async synchronize(read) {
27
+ if (this.disposed)
28
+ return;
29
+ this.synchronization?.abort();
30
+ const request = new AbortController();
31
+ this.synchronization = request;
32
+ const start = this.monotonic();
33
+ const signal = AbortSignal.any([request.signal, AbortSignal.timeout(5_000)]);
34
+ try {
35
+ const server = await read(signal);
36
+ if (this.disposed || signal.aborted || !timestamp(server))
37
+ return;
38
+ // Midpoint correction bounds the initial network-delay estimate without
39
+ // ever assuming the client and a remote server share an epoch clock.
40
+ this.observe(server + Math.max(0, this.monotonic() - start) / 2);
41
+ this.publish();
42
+ }
43
+ catch { /* Without a trusted anchor, retain native totals until a live event arrives. */ }
44
+ finally {
45
+ request.abort();
46
+ if (this.synchronization === request)
47
+ this.synchronization = undefined;
48
+ }
49
+ }
50
+ listen(listener) {
51
+ if (this.disposed)
52
+ return () => { };
53
+ this.listeners.add(listener);
54
+ return () => { this.listeners.delete(listener); };
55
+ }
56
+ /** Only views actually projecting a live turn keep this single 500ms ticker alive. */
57
+ tick(listener) {
58
+ if (this.disposed)
59
+ return () => { };
60
+ this.ticks.add(listener);
61
+ this.timer ??= setInterval(() => {
62
+ const now = this.now();
63
+ for (const tick of this.ticks)
64
+ tick(now);
65
+ }, 500);
66
+ this.timer.unref?.();
67
+ return () => {
68
+ this.ticks.delete(listener);
69
+ if (!this.ticks.size) {
70
+ clearInterval(this.timer);
71
+ this.timer = undefined;
72
+ }
73
+ };
74
+ }
75
+ canEstimate(sessionID, messageID) {
76
+ const latest = this.latest.get(sessionID);
77
+ return !this.disposed && !this.stopped.has(sessionID)
78
+ && (!latest || (latest.messageID === messageID && !latest.settled));
79
+ }
80
+ handle(event) {
81
+ if (this.disposed)
82
+ return;
83
+ if (event.type === "server.connected") {
84
+ this.anchor = undefined;
85
+ this.latest.clear();
86
+ this.stopped.clear();
87
+ }
88
+ const firstAnchor = !this.anchor && timestamp(event.created);
89
+ if (timestamp(event.created))
90
+ this.observe(event.created);
91
+ const data = (event.data && typeof event.data === "object" ? event.data : {});
92
+ const sessionID = data.sessionID;
93
+ if (event.type === "global.disposed" || event.type === "location.shutdown") {
94
+ this.anchor = undefined;
95
+ this.publish();
96
+ return;
97
+ }
98
+ if (sessionID) {
99
+ const latest = this.latest.get(sessionID);
100
+ // Late/replayed endings cannot stop a newer step.
101
+ const older = timestamp(event.created) && timestamp(latest?.created) && event.created < latest.created;
102
+ if (event.type === "session.deleted") {
103
+ this.latest.delete(sessionID);
104
+ this.stopped.add(sessionID);
105
+ this.publish(sessionID);
106
+ }
107
+ else if (!older && event.type === "session.step.started" && typeof data.assistantMessageID === "string") {
108
+ this.latest.set(sessionID, { messageID: data.assistantMessageID, settled: false,
109
+ ...(timestamp(event.created) ? { created: event.created } : {}) });
110
+ this.stopped.delete(sessionID);
111
+ this.publish(sessionID);
112
+ }
113
+ else if (!older && (event.type === "session.step.failed"
114
+ || (event.type === "session.step.ended" && data.finish && !["tool-calls", "unknown"].includes(data.finish)))) {
115
+ if (typeof data.assistantMessageID === "string" && (!latest || latest.messageID === data.assistantMessageID)) {
116
+ this.latest.set(sessionID, { messageID: data.assistantMessageID, settled: true,
117
+ ...(timestamp(event.created) ? { created: event.created } : {}) });
118
+ this.publish(sessionID);
119
+ }
120
+ }
121
+ else if (!older && event.type === "session.retry.scheduled" && latest && latest.messageID === data.assistantMessageID) {
122
+ latest.settled = false;
123
+ this.publish(sessionID);
124
+ }
125
+ else if (!older && ["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted", "session.idle"].includes(event.type)) {
126
+ this.stopped.add(sessionID);
127
+ this.publish(sessionID);
128
+ }
129
+ }
130
+ if (firstAnchor || event.type === "server.connected")
131
+ this.publish();
132
+ }
133
+ dispose() {
134
+ if (this.disposed)
135
+ return;
136
+ this.disposed = true;
137
+ this.synchronization?.abort();
138
+ this.unsubscribe?.();
139
+ clearInterval(this.timer);
140
+ this.timer = undefined;
141
+ this.anchor = undefined;
142
+ this.listeners.clear();
143
+ this.ticks.clear();
144
+ this.latest.clear();
145
+ this.stopped.clear();
146
+ }
147
+ observe(server) {
148
+ // Do not move the live estimate backwards for old/duplicated stream events.
149
+ if (!timestamp(server))
150
+ return;
151
+ this.anchor = { server: Math.max(this.now() ?? server, server), local: this.monotonic() };
152
+ }
153
+ publish(sessionID) { for (const listener of this.listeners)
154
+ listener(sessionID); }
155
+ }
package/dist/runtime.d.ts CHANGED
@@ -3,13 +3,35 @@ export interface RuntimeSummary {
3
3
  status: "loading" | "ready" | "stale" | "unavailable";
4
4
  /** Sum of one native response duration per turn in the viewed session's retained history. */
5
5
  milliseconds?: number | undefined;
6
+ /** Display-only projection; never becomes part of the native historical subtotal. */
7
+ estimatedMilliseconds?: number | undefined;
8
+ }
9
+ export interface PreparedRuntime {
10
+ summary: RuntimeSummary;
11
+ /** Only a response/continuation at the open tail can be projected while the host is running. */
12
+ turn?: {
13
+ messageID: string;
14
+ started: number;
15
+ milliseconds: number;
16
+ };
17
+ }
18
+ /** Native session metadata from the existing session read; omitted metadata is unknown. */
19
+ export interface RuntimeTime {
20
+ created: number;
21
+ idle?: number;
6
22
  }
7
23
  /**
8
24
  * OpenCode 2.0.26's TUI turnDuration/inputIndex: completed assistant time minus
9
25
  * the first user/synthetic input after the previous idle marker, or the nearest
10
- * input in legacy histories with no idle markers. With no input it falls back
11
- * to the assistant's creation time. Take only the final response in each turn:
26
+ * input in legacy histories with no idle markers. Native session metadata with
27
+ * no previous idle identifies the first turn, even before its first marker.
28
+ * With no input it falls back to the assistant's creation time.
29
+ * Take only the final response in each turn:
12
30
  * intermediate footer durations overlap and must never be added separately.
13
31
  * No execution-log API, client clock, child history, or additional reads.
14
32
  */
15
- export declare function summarizeRuntime(messages: readonly UsageMessage[], fork?: boolean): RuntimeSummary;
33
+ export declare function summarizeRuntime(messages: readonly UsageMessage[], fork?: boolean, time?: RuntimeTime): RuntimeSummary;
34
+ /** Scan history once per snapshot, keeping the current turn's overlapping subtotal separate. */
35
+ export declare function prepareRuntime(messages: readonly UsageMessage[], fork?: boolean, time?: RuntimeTime): PreparedRuntime;
36
+ /** Replace, rather than add to, an already-counted footer in the same steered turn. */
37
+ export declare function projectRuntime(prepared: PreparedRuntime, now: number | undefined): RuntimeSummary;
package/dist/runtime.js CHANGED
@@ -3,19 +3,31 @@ const timestamp = (value) => typeof value === "number" && value >= 0
3
3
  /**
4
4
  * OpenCode 2.0.26's TUI turnDuration/inputIndex: completed assistant time minus
5
5
  * the first user/synthetic input after the previous idle marker, or the nearest
6
- * input in legacy histories with no idle markers. With no input it falls back
7
- * to the assistant's creation time. Take only the final response in each turn:
6
+ * input in legacy histories with no idle markers. Native session metadata with
7
+ * no previous idle identifies the first turn, even before its first marker.
8
+ * With no input it falls back to the assistant's creation time.
9
+ * Take only the final response in each turn:
8
10
  * intermediate footer durations overlap and must never be added separately.
9
11
  * No execution-log API, client clock, child history, or additional reads.
10
12
  */
11
- export function summarizeRuntime(messages, fork = false) {
13
+ export function summarizeRuntime(messages, fork = false, time) {
14
+ return prepareRuntime(messages, fork, time).summary;
15
+ }
16
+ /** Scan history once per snapshot, keeping the current turn's overlapping subtotal separate. */
17
+ export function prepareRuntime(messages, fork = false, time) {
18
+ // A new session has not recorded an idle yet. Merely missing a message marker
19
+ // must not turn synthetic deliveries/steers into new turns during that run.
20
+ // Unknown metadata retains the native legacy fallback, as does a past idle.
21
+ const firstTurn = timestamp(time?.created) && time?.idle === undefined;
12
22
  // Even a copied marker identifies the host's turn semantics for an open fork.
13
- const legacy = !messages.some(message => message.type === "idle");
23
+ const legacy = !firstTurn && !messages.some(message => message.type === "idle");
14
24
  // Fork copies belong to the original session, including copied turn boundaries.
15
25
  const history = fork ? messages.filter(message => !/_\d+$/.test(message.id)) : messages;
16
26
  let milliseconds = 0, unavailable = false;
17
27
  let input;
18
28
  let response, completedResponse;
29
+ let lastAssistant;
30
+ let turnMilliseconds = 0;
19
31
  const finish = (closed) => {
20
32
  if (!response)
21
33
  return;
@@ -40,23 +52,29 @@ export function summarizeRuntime(messages, fork = false) {
40
52
  unavailable = true;
41
53
  return;
42
54
  }
43
- milliseconds += Math.max(0, completed - created);
55
+ turnMilliseconds = Math.max(0, completed - created);
56
+ milliseconds += turnMilliseconds;
44
57
  };
45
58
  for (const message of history) {
46
59
  if (message.type === "idle") {
47
60
  finish(true);
48
61
  input = undefined;
49
62
  response = completedResponse = undefined;
63
+ lastAssistant = undefined;
64
+ turnMilliseconds = 0;
50
65
  }
51
66
  else if (message.type === "user" || message.type === "synthetic") {
52
67
  if (legacy) {
53
68
  finish(true);
54
69
  input = undefined;
55
70
  response = completedResponse = undefined;
71
+ lastAssistant = undefined;
72
+ turnMilliseconds = 0;
56
73
  }
57
74
  input ??= message;
58
75
  }
59
76
  else if (message.type === "assistant") {
77
+ lastAssistant = message;
60
78
  // Matches native footer eligibility. Older imported messages can lack
61
79
  // finish metadata; their completed timestamp still permits a duration.
62
80
  if (!message.error && !message.retry && (message.finish === "tool-calls" || message.finish === "unknown"))
@@ -67,6 +85,25 @@ export function summarizeRuntime(messages, fork = false) {
67
85
  }
68
86
  }
69
87
  finish(false);
70
- return unavailable || !Number.isFinite(milliseconds)
71
- ? { status: "unavailable" } : { status: "ready", milliseconds };
88
+ if (unavailable || !Number.isFinite(milliseconds))
89
+ return { summary: { status: "unavailable" } };
90
+ const summary = { status: "ready", milliseconds };
91
+ const started = input ? input.time?.created : lastAssistant?.time?.created;
92
+ // Tool steps and scheduled retries can be completed without ending the turn.
93
+ // A terminal response must not keep growing during the later idle/cleanup transition.
94
+ const continuing = lastAssistant && (lastAssistant.retry || (!lastAssistant.error
95
+ && (lastAssistant.finish === "tool-calls" || lastAssistant.finish === "unknown"
96
+ || (!lastAssistant.finish && lastAssistant.time?.completed === undefined))));
97
+ if (!lastAssistant || !continuing || !timestamp(started) || !timestamp(lastAssistant.time?.created))
98
+ return { summary };
99
+ return { summary, turn: { messageID: lastAssistant.id, started, milliseconds: turnMilliseconds } };
100
+ }
101
+ /** Replace, rather than add to, an already-counted footer in the same steered turn. */
102
+ export function projectRuntime(prepared, now) {
103
+ const { summary, turn } = prepared;
104
+ if (summary.status !== "ready" || summary.milliseconds === undefined || !turn || !timestamp(now))
105
+ return summary;
106
+ const elapsed = Math.max(turn.milliseconds, now - turn.started, 0);
107
+ const estimatedMilliseconds = Math.round(summary.milliseconds - turn.milliseconds + elapsed);
108
+ return Number.isFinite(estimatedMilliseconds) ? { ...summary, estimatedMilliseconds } : summary;
72
109
  }
package/dist/source.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { OpenCodeClient, SessionInfo } from "@opencode/client";
2
2
  import type { ContextSources } from "./context-sources.js";
3
+ import type { ReadActiveSessions } from "./performance.js";
3
4
  import type { PriceCatalog, UsageMessage } from "./usage.js";
4
- export type Session = Pick<SessionInfo, "id" | "parentID" | "fork" | "model" | "location" | "title" | "revert">;
5
+ export type Session = Pick<SessionInfo, "id" | "parentID" | "fork" | "model" | "location" | "title" | "revert"> & Partial<Pick<SessionInfo, "time">>;
5
6
  export interface Page<T> {
6
7
  data: T[];
7
8
  cursor: {
@@ -21,6 +22,7 @@ export interface UsageSource {
21
22
  messages(id: string, cursor: string | undefined, signal: AbortSignal): Promise<Page<UsageMessage>>;
22
23
  model(session: Session, signal: AbortSignal): Promise<ActiveModel>;
23
24
  composition?(session: Session, signal: AbortSignal): Promise<ContextSources | undefined>;
25
+ active?: ReadActiveSessions;
24
26
  }
25
27
  export declare function createSource(client: OpenCodeClient): UsageSource;
26
28
  export declare function pages<T>(read: (cursor?: string) => Promise<Page<T>>, signal: AbortSignal): Promise<T[]>;
package/dist/source.js CHANGED
@@ -3,6 +3,16 @@ import { parseContextSources } from "./context-sources.js";
3
3
  import { modelKey } from "./usage.js";
4
4
  export function createSource(client) {
5
5
  return {
6
+ async active(signal) {
7
+ // V2 returns the complete process-wide foreground-drain set, already unwrapped
8
+ // by the Promise client. Cached TUI status can still be stale after reconnect.
9
+ const active = await client.session.active({ signal });
10
+ if (!active || typeof active !== "object" || Array.isArray(active)
11
+ || Object.values(active).some(value => !value || value.type !== "running")) {
12
+ throw new Error("Invalid active session snapshot");
13
+ }
14
+ return new Set(Object.keys(active));
15
+ },
6
16
  session: (sessionID, signal) => client.session.get({ sessionID }, { signal }),
7
17
  children: (parentID, cursor, signal) => client.session.list(cursor ? { cursor } : { parentID, order: "asc", limit: 100 }, { signal }),
8
18
  // Unlike session.context(), message.list() includes pre-compaction history.