taskchef 7.19.0 → 7.20.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/.codex-plugin/plugin.json +1 -1
- package/README.md +37 -10
- package/docs/images/ccusage-token-consumption.png +0 -0
- package/docs/spec.md +62 -13
- package/docs/workflows.md +13 -10
- package/package.json +2 -1
- package/src/dashboard/app.js +97 -16
- package/src/dashboard/index.html +4 -0
- package/src/dashboard/styles.css +7 -0
- package/src/dashboard-manager.js +10 -3
- package/src/dashboard.js +18 -1
- package/src/mcp.js +17 -4
- package/src/usage-tracker.js +425 -0
- package/src/usage.js +388 -0
- package/src/version.js +1 -1
package/README.md
CHANGED
|
@@ -25,6 +25,12 @@ request -> recorded TaskChef task -> Codex executor -> request/result turn timel
|
|
|
25
25
|
TaskChef requires Node.js 18 or newer, Git, Codex desktop, and local access to
|
|
26
26
|
the projects that will receive work.
|
|
27
27
|
|
|
28
|
+
Install [`ccusage`](https://github.com/ccusage/ccusage) separately when you want
|
|
29
|
+
the optional dashboard token and API-equivalent cost estimates. TaskChef calls
|
|
30
|
+
its structured offline Codex report and does not parse Codex rollout files
|
|
31
|
+
itself. Lifecycle reporting and the dashboard continue to work when `ccusage`
|
|
32
|
+
is absent or incompatible.
|
|
33
|
+
|
|
28
34
|
```sh
|
|
29
35
|
codex plugin marketplace add favoyang/codex-plugins
|
|
30
36
|
codex plugin add taskchef@favoyang-plugins
|
|
@@ -50,6 +56,7 @@ The canonical workspace is `~/.agents/taskchef`. TaskChef owns only:
|
|
|
50
56
|
AGENTS.md managed dispatcher instructions plus user additions
|
|
51
57
|
taskchef.json schema-2 Codex project index, dashboard preference, and delegation metadata
|
|
52
58
|
tasks.jsonl one task snapshot per line (schema 9; schema 4-8 migration supported)
|
|
59
|
+
.taskchef-usage.json optional mode-0600 ccusage snapshot and turn-boundary cache
|
|
53
60
|
```
|
|
54
61
|
|
|
55
62
|
Index or inspect Codex projects conversationally:
|
|
@@ -269,8 +276,23 @@ Codex CLI at the canonical ChatGPT or Codex desktop app location under
|
|
|
269
276
|
`/Applications`, to archive the exact thread UUID. The Codex chat leaves active chat lists while the TaskChef record
|
|
270
277
|
and its activity timeline remain unchanged. If the bundled CLI is unavailable,
|
|
271
278
|
the dashboard does not fall back to another `codex` executable from `PATH`.
|
|
279
|
+
|
|
280
|
+
Task details also show whole-task and per-turn token usage when `ccusage` can
|
|
281
|
+
map the linked Codex thread. A completed turn briefly shows “Tokens:
|
|
282
|
+
calculating…” while TaskChef performs bounded deferred reconciliation, because
|
|
283
|
+
the terminal lifecycle callback precedes Codex's final output write. Historical
|
|
284
|
+
tasks may show a trustworthy task total while older turns remain “Tokens
|
|
285
|
+
unavailable” when no cumulative turn boundaries were recorded. Input, cached
|
|
286
|
+
input, output, reasoning, and total counts retain ccusage's categories. Dollar
|
|
287
|
+
figures are labeled API-equivalent estimates; zero-priced unknown models show
|
|
288
|
+
cost unavailable rather than a misleading `$0.00`.
|
|
272
289
|
The header shows the running TaskChef package version reported by the same
|
|
273
290
|
bounded health identity used for compatible-listener checks.
|
|
291
|
+
The canonical port is owned by a dashboard initialized in the TaskChef MCP host
|
|
292
|
+
before its tool transport connects. Health identity records an `mcp` launcher,
|
|
293
|
+
and MCP recovery reuses only another exact-compatible MCP-launched dashboard;
|
|
294
|
+
a foreground `taskchef dashboard` process is intentionally standalone so its
|
|
295
|
+
child commands cannot silently inherit an agent-shell sandbox.
|
|
274
296
|
Task and result times are relative through 29 days (with minute detail for the
|
|
275
297
|
first six hours), then use a locale-aware calendar date. Each time is a keyboard-
|
|
276
298
|
accessible toggle for its full locale-aware date and time, and one shared
|
|
@@ -281,16 +303,17 @@ revision, so reconnects and non-semantic rewrites do not replay a notice and a
|
|
|
281
303
|
later task state cannot rewrite an older notice. A notice remains readable if
|
|
282
304
|
its task disappears; selecting it then explains that current details are no
|
|
283
305
|
longer available.
|
|
284
|
-
It does not mutate TaskChef data and prints its local URL.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
306
|
+
It does not mutate TaskChef data and prints its local URL. A foreground
|
|
307
|
+
dashboard identifies itself as standalone and is never reused on the canonical
|
|
308
|
+
MCP port. If a standalone, unknown, different-workspace, or stale-version
|
|
309
|
+
process owns port 3210, TaskChef reports a concise conflict and never kills or
|
|
310
|
+
replaces that process. The foreground CLI similarly asks you to stop the
|
|
311
|
+
listener or choose another `--port`.
|
|
290
312
|
|
|
291
313
|
The health endpoint contains only a fixed service marker, health schema,
|
|
292
|
-
TaskChef version, dashboard-server version,
|
|
293
|
-
no task data, credentials, environment variables, process control,
|
|
314
|
+
TaskChef version, dashboard-server version, canonical workspace, and launcher.
|
|
315
|
+
It exposes no task data, credentials, environment variables, process control,
|
|
316
|
+
or secrets.
|
|
294
317
|
|
|
295
318
|

|
|
296
319
|
|
|
@@ -298,6 +321,8 @@ no task data, credentials, environment variables, process control, or secrets.
|
|
|
298
321
|
|
|
299
322
|

|
|
300
323
|
|
|
324
|
+

|
|
325
|
+
|
|
301
326
|

|
|
302
327
|
|
|
303
328
|
## Common recovery
|
|
@@ -351,13 +376,15 @@ does not necessarily reload Codex. Then run `$taskchef-dashboard` (or call
|
|
|
351
376
|
|
|
352
377
|
- the expected released TaskChef version;
|
|
353
378
|
- the expected dashboard protocol `serverVersion`;
|
|
379
|
+
- the `mcp` dashboard launcher;
|
|
354
380
|
- the canonical TaskChef workspace path;
|
|
355
381
|
- the canonical `http://127.0.0.1:3210/` URL.
|
|
356
382
|
|
|
357
383
|
The release-install sequence is therefore: install plugin, activate or reload
|
|
358
384
|
the new MCP process, ensure the dashboard, then verify TaskChef version,
|
|
359
|
-
protocol `serverVersion`, canonical workspace, and URL.
|
|
360
|
-
servers may be reused; unknown listeners
|
|
385
|
+
protocol `serverVersion`, `mcp` launcher, canonical workspace, and URL.
|
|
386
|
+
Exact-compatible MCP servers may be reused; standalone and unknown listeners
|
|
387
|
+
remain untouched.
|
|
361
388
|
|
|
362
389
|
## Development
|
|
363
390
|
|
|
Binary file
|
package/docs/spec.md
CHANGED
|
@@ -28,7 +28,7 @@ is dated research, not contract.
|
|
|
28
28
|
| **Last semantic result** | The final result-history entry, exposed through the derived `lastResult` compatibility alias. |
|
|
29
29
|
| **Turn reference** | Required lifecycle identity for one executor prompt. It is the native Codex turn ID when available, otherwise a retained client-generated UUID. |
|
|
30
30
|
| **Current turn ID** | Optional Codex metadata for the reported prompt; null when native turn reading is unavailable. |
|
|
31
|
-
| **Dashboard** | The loopback, read-only UI derived from validated workspace snapshots and bounded native actions. |
|
|
31
|
+
| **Dashboard** | The loopback, read-only UI derived from validated workspace snapshots, optional local usage projections, and bounded native actions. |
|
|
32
32
|
| **Skill** | One packaged agent procedure: `taskchef-bootstrap`, `taskchef-dashboard`, `taskchef-delegate`, `taskchef-executor`, or `taskchef-copilot`. |
|
|
33
33
|
|
|
34
34
|
## Components and ownership
|
|
@@ -56,7 +56,8 @@ is dated research, not contract.
|
|
|
56
56
|
|
|
57
57
|
## Workspace contract
|
|
58
58
|
|
|
59
|
-
TaskChef MUST manage only `AGENTS.md`, `taskchef.json`,
|
|
59
|
+
TaskChef MUST manage only `AGENTS.md`, `taskchef.json`, `tasks.jsonl`, and the
|
|
60
|
+
optional `.taskchef-usage.json` cache
|
|
60
61
|
inside the dispatcher workspace. It MUST preserve unrelated paths.
|
|
61
62
|
|
|
62
63
|
`taskchef.json` MUST have schema version 2, the following required fields, and
|
|
@@ -116,18 +117,63 @@ from the final derived semantic result. Interrupted outcomes MUST be excluded.
|
|
|
116
117
|
These projections MUST NOT be persisted in schema 9 and remain compatibility
|
|
117
118
|
aliases for existing callers.
|
|
118
119
|
|
|
120
|
+
## Optional usage projection
|
|
121
|
+
|
|
122
|
+
TaskChef MAY invoke an installed `ccusage` executable as an optional local
|
|
123
|
+
adapter using `ccusage codex session --json --offline`. TaskChef MUST NOT parse,
|
|
124
|
+
store, or serve raw Codex rollout files, prompts, responses, transcripts, or
|
|
125
|
+
reasoning. Analyzer absence, timeout, malformed output, unknown pricing, or an
|
|
126
|
+
unresolved thread MUST NOT block lifecycle tools or dashboard loading.
|
|
127
|
+
|
|
128
|
+
The mode-0600 `.taskchef-usage.json` cache stores only normalized cumulative
|
|
129
|
+
token boundaries, per-turn deltas, model names, estimated cost, source version,
|
|
130
|
+
and freshness. It is independent of schema-9 task records so legacy logs remain
|
|
131
|
+
readable. Writes MUST use the workspace lock and atomic replacement. Symlinked
|
|
132
|
+
or unsupported cache files MUST be rejected. Writes MUST compact the derived
|
|
133
|
+
cache to recent task projections, recent per-turn results, and the latest
|
|
134
|
+
cumulative boundary. An oversized legacy cache MUST be treated as rebuildable
|
|
135
|
+
derived data so it cannot permanently disable usage reporting.
|
|
136
|
+
|
|
137
|
+
A linked Codex thread MAY map to multiple ccusage session segments. TaskChef
|
|
138
|
+
MUST aggregate only records whose primary durable thread UUID is that exact
|
|
139
|
+
identity; a UUID appearing only as a nested suffix MUST NOT be attributed to
|
|
140
|
+
the parent or child TaskChef task. It MUST retain input, cached-input, output,
|
|
141
|
+
reasoning-output, and total fields without adding cached or reasoning subsets
|
|
142
|
+
into totals a second time.
|
|
143
|
+
|
|
144
|
+
After a terminal report, TaskChef MUST mark the turn `calculating` and perform
|
|
145
|
+
bounded deferred reconciliation without delaying the lifecycle response.
|
|
146
|
+
Per-turn usage MUST be a non-negative delta between adjacent reliable cumulative
|
|
147
|
+
boundaries. A first recorded turn MAY use zero as its baseline. Historical
|
|
148
|
+
turns without boundaries and decreasing or ambiguous snapshots MUST be labeled
|
|
149
|
+
`unavailable`, never zero or estimated. A historical task MAY still show its
|
|
150
|
+
resolvable cumulative total. A boundary is reliable only after two consecutive
|
|
151
|
+
samples agree. Exhausted unstable sampling and a newer turn beginning before
|
|
152
|
+
stabilization MUST leave that turn unavailable and MUST NOT establish a delta
|
|
153
|
+
baseline. Zero is valid for the first turn only when TaskChef observed that turn
|
|
154
|
+
in progress before its terminal report; a first historical terminal turn MUST
|
|
155
|
+
remain unavailable even when its cumulative task total is resolvable.
|
|
156
|
+
|
|
157
|
+
Every available projection MUST identify ccusage, its version when available,
|
|
158
|
+
and freshness. Dollar values MUST be labeled API-equivalent estimates. Positive
|
|
159
|
+
token usage with a zero or missing analyzer cost MUST display cost unavailable,
|
|
160
|
+
not `$0.00`.
|
|
161
|
+
|
|
119
162
|
Task IDs and non-null thread identities MUST be unique. The immutable intent
|
|
120
163
|
fields MUST NOT change after recording.
|
|
121
164
|
|
|
122
165
|
## Required lifecycle
|
|
123
166
|
|
|
124
|
-
|
|
125
|
-
same serialized dashboard ensure path once by default. It MUST read the
|
|
167
|
+
Before the canonical TaskChef MCP server connects and exposes its transport, it
|
|
168
|
+
MUST invoke the same serialized dashboard ensure path once by default. It MUST read the
|
|
126
169
|
canonical configuration and skip this only for explicit
|
|
127
170
|
`dashboard.autostart: false`. Initialization failures, invalid workspace state,
|
|
128
171
|
port conflicts, and dashboard errors MUST NOT prevent tool registration or MCP
|
|
129
172
|
availability. They MUST emit only a bounded non-sensitive diagnostic through
|
|
130
173
|
the MCP process logging channel. MCP initialization MUST NOT open a browser.
|
|
174
|
+
If transport connection fails after dashboard startup, the MCP server MUST
|
|
175
|
+
best-effort close both its owned dashboard and partially attached transport,
|
|
176
|
+
then propagate the original connection failure even when cleanup also fails.
|
|
131
177
|
|
|
132
178
|
At the start of every dispatcher turn, the dispatcher SHOULD call
|
|
133
179
|
`ensure_dashboard` best-effort. Failure MUST NOT block direct TaskChef answers,
|
|
@@ -228,6 +274,7 @@ HTTP server; it does not mutate dispatcher workspace files.
|
|
|
228
274
|
```text
|
|
229
275
|
{ dashboard: {
|
|
230
276
|
action: "started" | "reused",
|
|
277
|
+
launcher: "mcp",
|
|
231
278
|
url: "http://127.0.0.1:3210/",
|
|
232
279
|
workspace: string,
|
|
233
280
|
taskchefVersion: string,
|
|
@@ -235,20 +282,22 @@ HTTP server; it does not mutate dispatcher workspace files.
|
|
|
235
282
|
} }
|
|
236
283
|
```
|
|
237
284
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
285
|
+
MCP initialization MUST complete its best-effort dashboard start before exposing
|
|
286
|
+
the MCP transport. Calls MUST serialize within one MCP process. The first call
|
|
287
|
+
starts an owned dashboard or reuses an exact compatible MCP-launched listener;
|
|
288
|
+
later and concurrent calls are idempotent and report reuse after the single start. The stable default MUST
|
|
241
289
|
bind only to `127.0.0.1:3210` and MUST NOT accept a model-supplied workspace,
|
|
242
290
|
host, or port.
|
|
243
291
|
|
|
244
292
|
Before reuse, TaskChef MUST query a bounded loopback identity endpoint and
|
|
245
293
|
require the exact fixed service/schema, TaskChef version, dashboard-server
|
|
246
|
-
version,
|
|
247
|
-
or stale-version listener MUST produce a concise actionable conflict. TaskChef
|
|
294
|
+
version, canonical workspace, and `mcp` launcher identity. A standalone,
|
|
295
|
+
unknown, malformed, different-workspace, or stale-version listener MUST produce a concise actionable conflict. TaskChef
|
|
248
296
|
MUST NOT kill, replace, signal, or otherwise control that listener. A startup
|
|
249
297
|
failure MUST leave no owned listener. The MCP server MUST close its owned
|
|
250
|
-
dashboard when its transport or process shuts down
|
|
251
|
-
|
|
298
|
+
dashboard when its transport or process shuts down. This keeps dashboard child
|
|
299
|
+
operations in the MCP host environment and prevents an agent-shell or foreground
|
|
300
|
+
CLI dashboard from being mistaken for the canonical MCP-owned runtime.
|
|
252
301
|
|
|
253
302
|
The packaged `$taskchef-dashboard` skill MUST call this tool, report `started`
|
|
254
303
|
or `reused`, and return the canonical clickable URL. It MAY use an available
|
|
@@ -510,6 +559,6 @@ closed.
|
|
|
510
559
|
Installing or replacing plugin files MUST NOT be described as activating the
|
|
511
560
|
new MCP code. Release verification MUST install the plugin, activate or reload
|
|
512
561
|
the new MCP process, ensure the dashboard, and verify the expected TaskChef
|
|
513
|
-
version, dashboard protocol `serverVersion`, canonical workspace,
|
|
514
|
-
URL. Exact-compatible listener reuse remains valid; installation MUST NOT be
|
|
562
|
+
version, dashboard protocol `serverVersion`, `mcp` launcher, canonical workspace,
|
|
563
|
+
and canonical URL. Exact-compatible MCP-listener reuse remains valid; installation MUST NOT be
|
|
515
564
|
claimed to reload Codex automatically.
|
package/docs/workflows.md
CHANGED
|
@@ -21,6 +21,8 @@ research.
|
|
|
21
21
|
| `src/cli.js` | Administration, normalized cached briefs, inspection, diagnostics, and dashboard startup. |
|
|
22
22
|
| `src/dashboard.js` | Versioned health identity, validated compact snapshots, SSE fan-out, on-demand details, and bounded open actions. |
|
|
23
23
|
| `src/dashboard-manager.js` | Concurrent singleton ensure, exact listener reuse, conflicts, and owned shutdown. |
|
|
24
|
+
| `src/usage.js` | Optional bounded ccusage execution, exact primary-thread mapping, normalized aggregation, and the private usage cache. |
|
|
25
|
+
| `src/usage-tracker.js` | Deferred sampling, cumulative boundaries, historical availability, and per-turn deltas. |
|
|
24
26
|
|
|
25
27
|
The MCP process resolves `TASKCHEF_WORKSPACE` once and never accepts a model
|
|
26
28
|
supplied path. The CLI resolves `--workspace`, then the environment, then the
|
|
@@ -40,11 +42,11 @@ sequenceDiagram
|
|
|
40
42
|
participant H as Loopback health
|
|
41
43
|
participant S as Dashboard server
|
|
42
44
|
M->>M: Read dashboard.autostart (absent means true)
|
|
43
|
-
M->>M: Best-effort ensure
|
|
45
|
+
M->>M: Best-effort ensure before MCP transport connects
|
|
44
46
|
D->>M: ensure_dashboard()
|
|
45
47
|
M->>M: Serialize concurrent ensure calls
|
|
46
48
|
M->>H: GET 127.0.0.1:3210/api/health
|
|
47
|
-
alt Exact service, versions, and
|
|
49
|
+
alt Exact service, versions, canonical workspace, and MCP launcher
|
|
48
50
|
H-->>M: Bounded compatible identity
|
|
49
51
|
M-->>D: reused, URL, workspace, versions
|
|
50
52
|
else No listener
|
|
@@ -52,7 +54,7 @@ sequenceDiagram
|
|
|
52
54
|
M->>S: Start in this MCP process on 127.0.0.1:3210
|
|
53
55
|
S-->>M: Owned server
|
|
54
56
|
M-->>D: started, URL, workspace, versions
|
|
55
|
-
else
|
|
57
|
+
else Standalone, unknown, stale, or different workspace
|
|
56
58
|
H-->>M: Missing or incompatible identity
|
|
57
59
|
M-->>D: Actionable conflict, listener untouched
|
|
58
60
|
end
|
|
@@ -62,9 +64,10 @@ sequenceDiagram
|
|
|
62
64
|
```
|
|
63
65
|
|
|
64
66
|
When the MCP transport or plugin process closes, it closes only the server it
|
|
65
|
-
started. A
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
started. A foreground `taskchef dashboard` listener identifies itself as
|
|
68
|
+
standalone and is never reused as the canonical MCP dashboard, because its
|
|
69
|
+
archive child process may inherit a different host environment. No TaskChef
|
|
70
|
+
path terminates an incompatible listener or installs OS persistence.
|
|
68
71
|
|
|
69
72
|
Autostart and explicit ensures share the same manager promise, so concurrent
|
|
70
73
|
initialization and recovery calls produce at most one owned listener. An
|
|
@@ -79,13 +82,13 @@ The practical release handoff ends in this order:
|
|
|
79
82
|
1. Install the released plugin.
|
|
80
83
|
2. Activate or reload its new TaskChef MCP process.
|
|
81
84
|
3. Run `$taskchef-dashboard` or call `ensure_dashboard`.
|
|
82
|
-
4. Verify the expected TaskChef version, protocol `serverVersion`,
|
|
83
|
-
workspace, and canonical URL returned by the dashboard identity.
|
|
85
|
+
4. Verify the expected TaskChef version, protocol `serverVersion`, `mcp`
|
|
86
|
+
launcher, canonical workspace, and canonical URL returned by the dashboard identity.
|
|
84
87
|
|
|
85
88
|
Replacing plugin files alone cannot execute autostart because old code remains
|
|
86
89
|
in the already-running MCP process. Installation does not necessarily reload
|
|
87
|
-
Codex. An exact-compatible dashboard may be reused;
|
|
88
|
-
never terminated or replaced.
|
|
90
|
+
Codex. An exact-compatible MCP dashboard may be reused; a standalone or unknown
|
|
91
|
+
listener is never terminated or replaced.
|
|
89
92
|
|
|
90
93
|
## Normal delegation and self-linking
|
|
91
94
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taskchef",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.20.0",
|
|
4
4
|
"description": "A non-blocking interactive dispatcher for visible Codex tasks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Favo Yang",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"docs/workflows.md",
|
|
28
28
|
"docs/firstmate-taskchef-comparison.md",
|
|
29
29
|
"docs/images/dashboard-identity.jpg",
|
|
30
|
+
"docs/images/ccusage-token-consumption.png",
|
|
30
31
|
"docs/images/notification-event-snapshots.jpg",
|
|
31
32
|
"docs/images/result-history-dashboard.jpg",
|
|
32
33
|
"docs/images/interrupted-turn-recovery.jpg",
|
package/src/dashboard/app.js
CHANGED
|
@@ -25,6 +25,9 @@ import {
|
|
|
25
25
|
} from "./github-links.js";
|
|
26
26
|
import { formatRelativeTime, RelativeTimeController, parsedTimestamp } from "./time.js";
|
|
27
27
|
|
|
28
|
+
const USAGE_POLL_INTERVAL_MS = 1_500;
|
|
29
|
+
const MAX_USAGE_POLL_ATTEMPTS = 40;
|
|
30
|
+
|
|
28
31
|
const state = {
|
|
29
32
|
archivedThreadIds: new Set(),
|
|
30
33
|
tasks: [],
|
|
@@ -57,6 +60,7 @@ const elements = {
|
|
|
57
60
|
dialogRelatedLinks: document.querySelector("#dialog-related-links"),
|
|
58
61
|
dialogResults: document.querySelector("#dialog-results"),
|
|
59
62
|
dialogTitle: document.querySelector("#dialog-title"),
|
|
63
|
+
dialogUsage: document.querySelector("#dialog-usage"),
|
|
60
64
|
dismissDashboardMessage: document.querySelector("#dismiss-dashboard-message"),
|
|
61
65
|
emptyState: document.querySelector("#empty-state"),
|
|
62
66
|
notifications: document.querySelector("#notifications"),
|
|
@@ -347,6 +351,58 @@ function detailRow(term, value) {
|
|
|
347
351
|
return [dt, dd];
|
|
348
352
|
}
|
|
349
353
|
|
|
354
|
+
const tokenFormatter = new Intl.NumberFormat();
|
|
355
|
+
|
|
356
|
+
function formatEstimatedCost(value) {
|
|
357
|
+
if (typeof value !== "number") return "cost unavailable";
|
|
358
|
+
if (value === 0) return "estimated $0.00";
|
|
359
|
+
return `estimated $${value < 0.01 ? value.toFixed(4) : value.toFixed(2)}`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function usageBreakdownText(usage) {
|
|
363
|
+
return [
|
|
364
|
+
`${tokenFormatter.format(usage.inputTokens)} input`,
|
|
365
|
+
`${tokenFormatter.format(usage.cachedInputTokens)} cached input`,
|
|
366
|
+
`${tokenFormatter.format(usage.outputTokens)} output`,
|
|
367
|
+
`${tokenFormatter.format(usage.reasoningOutputTokens)} reasoning`,
|
|
368
|
+
].join(" · ");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function usagePresentation(usage, { wholeTask = false } = {}) {
|
|
372
|
+
const container = document.createElement("div");
|
|
373
|
+
container.className = `usage-summary usage-${usage?.status ?? "calculating"}`;
|
|
374
|
+
if (!usage || usage.status === "calculating") {
|
|
375
|
+
const indicator = document.createElement("span");
|
|
376
|
+
indicator.className = "usage-spinner";
|
|
377
|
+
indicator.setAttribute("aria-hidden", "true");
|
|
378
|
+
const text = document.createElement("span");
|
|
379
|
+
text.textContent = "Tokens: calculating…";
|
|
380
|
+
container.replaceChildren(indicator, text);
|
|
381
|
+
return container;
|
|
382
|
+
}
|
|
383
|
+
if (usage.status !== "available") {
|
|
384
|
+
container.textContent = `Tokens unavailable${usage.reason ? ` — ${usage.reason}` : "."}`;
|
|
385
|
+
return container;
|
|
386
|
+
}
|
|
387
|
+
const headline = document.createElement("strong");
|
|
388
|
+
headline.textContent = `${tokenFormatter.format(usage.totalTokens)} tokens · ${formatEstimatedCost(usage.estimatedCostUsd)}`;
|
|
389
|
+
const breakdown = document.createElement("span");
|
|
390
|
+
breakdown.textContent = usageBreakdownText(usage);
|
|
391
|
+
container.append(headline, breakdown);
|
|
392
|
+
const provenance = document.createElement("span");
|
|
393
|
+
const version = usage.provenance?.version ? ` ${usage.provenance.version}` : "";
|
|
394
|
+
const freshness = usage.sourceUpdatedAt ?? usage.sampledAt;
|
|
395
|
+
provenance.textContent = `Source: ccusage${version}${freshness ? ` · updated ${formatRelativeTime(freshness)}` : ""}. Dollar cost is an API-equivalent estimate${wholeTask ? " for the task" : " for this turn"}.`;
|
|
396
|
+
container.append(provenance);
|
|
397
|
+
return container;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function usageStillCalculating(task) {
|
|
401
|
+
return task.usage?.status === "calculating"
|
|
402
|
+
|| (task.status !== "working" && Object.values(task.usage?.turns ?? {})
|
|
403
|
+
.some((turn) => turn.status === "calculating"));
|
|
404
|
+
}
|
|
405
|
+
|
|
350
406
|
function turnTimeline(task) {
|
|
351
407
|
if (task.turns.length === 0) {
|
|
352
408
|
const empty = document.createElement("p");
|
|
@@ -387,7 +443,20 @@ function turnTimeline(task) {
|
|
|
387
443
|
const turnMetadata = document.createElement("p");
|
|
388
444
|
turnMetadata.className = "result-history-turn";
|
|
389
445
|
turnMetadata.textContent = `Turn ref ${turn.turnRef ?? "not recorded"}; Codex turn ${turn.turnId ?? "unavailable"}`;
|
|
390
|
-
|
|
446
|
+
const turnUsage = task.usage?.turns?.[turn.turnRef ?? turn.turnId] ?? (
|
|
447
|
+
turn.result === null
|
|
448
|
+
? { status: "calculating" }
|
|
449
|
+
: { status: "unavailable", reason: "No reliable turn boundary is available." }
|
|
450
|
+
);
|
|
451
|
+
item.append(
|
|
452
|
+
header,
|
|
453
|
+
requestLabel,
|
|
454
|
+
request,
|
|
455
|
+
resultLabel,
|
|
456
|
+
result,
|
|
457
|
+
usagePresentation(turnUsage),
|
|
458
|
+
turnMetadata,
|
|
459
|
+
);
|
|
391
460
|
return item;
|
|
392
461
|
});
|
|
393
462
|
}
|
|
@@ -418,6 +487,12 @@ function renderDialog(task) {
|
|
|
418
487
|
(task.relatedGitHubLinks?.length ?? 0) === 0 && !task.relatedGitHubLinksTruncated
|
|
419
488
|
);
|
|
420
489
|
elements.dialogResults.replaceChildren(...turnTimeline(detailedTask));
|
|
490
|
+
elements.dialogUsage.replaceChildren(usagePresentation(
|
|
491
|
+
task.usage?.status === "available" && task.usage.task
|
|
492
|
+
? { status: "available", ...task.usage.task }
|
|
493
|
+
: task.usage ?? { status: task.threadId ? "calculating" : "unavailable" },
|
|
494
|
+
{ wholeTask: true },
|
|
495
|
+
));
|
|
421
496
|
elements.dialogInstruction.textContent = task.instruction;
|
|
422
497
|
elements.copyTaskId.disabled = !task.id;
|
|
423
498
|
setCopyTaskIdLabel("Copy Task ID");
|
|
@@ -464,22 +539,28 @@ async function openDialog(task) {
|
|
|
464
539
|
const requestGeneration = ++detailRequestGeneration;
|
|
465
540
|
renderDialog(task);
|
|
466
541
|
if (!elements.dialog.open) elements.dialog.showModal();
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
542
|
+
const load = async (attempt = 0) => {
|
|
543
|
+
try {
|
|
544
|
+
const response = await fetch(`/api/tasks/${encodeURIComponent(task.id)}`);
|
|
545
|
+
if (!response.ok) throw new Error("Task details are unavailable.");
|
|
546
|
+
const detail = await response.json();
|
|
547
|
+
if (
|
|
548
|
+
requestGeneration === detailRequestGeneration
|
|
549
|
+
&& state.selectedTask?.id === task.id
|
|
550
|
+
&& elements.dialog.open
|
|
551
|
+
) {
|
|
552
|
+
renderDialog(detail.task);
|
|
553
|
+
if (usageStillCalculating(detail.task) && attempt < MAX_USAGE_POLL_ATTEMPTS) {
|
|
554
|
+
setTimeout(() => load(attempt + 1), USAGE_POLL_INTERVAL_MS);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
} catch {
|
|
558
|
+
if (state.selectedTask?.id === task.id) {
|
|
559
|
+
showMessage("Task activity timeline is temporarily unavailable.");
|
|
560
|
+
}
|
|
481
561
|
}
|
|
482
|
-
}
|
|
562
|
+
};
|
|
563
|
+
await load();
|
|
483
564
|
}
|
|
484
565
|
|
|
485
566
|
function taskCard(task) {
|
package/src/dashboard/index.html
CHANGED
|
@@ -116,6 +116,10 @@
|
|
|
116
116
|
<button id="archive-codex" class="danger-button" type="button" aria-label="Archive this chat in Codex" hidden>Archive chat</button>
|
|
117
117
|
</div>
|
|
118
118
|
<nav id="dialog-related-links" class="github-links" aria-label="Related GitHub links" hidden></nav>
|
|
119
|
+
<section>
|
|
120
|
+
<h3>Usage</h3>
|
|
121
|
+
<div id="dialog-usage" class="usage-panel" aria-live="polite"></div>
|
|
122
|
+
</section>
|
|
119
123
|
<section>
|
|
120
124
|
<h3>Activity timeline</h3>
|
|
121
125
|
<div id="dialog-results" class="result-history"></div>
|
package/src/dashboard/styles.css
CHANGED
|
@@ -165,6 +165,13 @@ dialog section + section { margin-top: 24px; }
|
|
|
165
165
|
.result-history-item h4 { margin: 10px 0 3px; color: var(--muted); font-size: 0.72rem; letter-spacing: 0.04em; text-transform: uppercase; }
|
|
166
166
|
.result-history-turn, .result-history-empty { color: var(--muted); font-size: 0.78rem; overflow-wrap: anywhere; }
|
|
167
167
|
.result-history-empty { margin: 0; }
|
|
168
|
+
.usage-panel { margin-top: 8px; }
|
|
169
|
+
.usage-summary { display: grid; gap: 3px; margin: 10px 0 4px; color: var(--muted); font-size: 0.8rem; }
|
|
170
|
+
.usage-summary strong { color: var(--text); font-size: 0.9rem; }
|
|
171
|
+
.usage-calculating { display: flex; align-items: center; gap: 7px; }
|
|
172
|
+
.usage-spinner { width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: usage-spin 0.8s linear infinite; }
|
|
173
|
+
@keyframes usage-spin { to { transform: rotate(360deg); } }
|
|
174
|
+
@media (prefers-reduced-motion: reduce) { .usage-spinner { animation: none; border-top-color: var(--border); background: var(--accent); } }
|
|
168
175
|
pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius: 7px; background: var(--surface-muted); white-space: pre-wrap; overflow-wrap: anywhere; font: 0.86rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
169
176
|
.metadata { display: grid; grid-template-columns: minmax(100px, 150px) 1fr; margin: 0; font-size: 0.88rem; }
|
|
170
177
|
.metadata dt, .metadata dd { padding: 7px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
|
package/src/dashboard-manager.js
CHANGED
|
@@ -14,13 +14,14 @@ const DEFAULT_HOST = "127.0.0.1";
|
|
|
14
14
|
const DEFAULT_PORT = 3210;
|
|
15
15
|
const HEALTH_TIMEOUT_MS = 750;
|
|
16
16
|
|
|
17
|
-
function expectedIdentity(workspace, taskchefVersion, serverVersion) {
|
|
17
|
+
function expectedIdentity(workspace, taskchefVersion, serverVersion, launcher) {
|
|
18
18
|
return {
|
|
19
19
|
schemaVersion: 1,
|
|
20
20
|
service: "taskchef-dashboard",
|
|
21
21
|
taskchefVersion,
|
|
22
22
|
serverVersion,
|
|
23
23
|
workspace,
|
|
24
|
+
launcher,
|
|
24
25
|
};
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -107,6 +108,7 @@ export function createDashboardManager({
|
|
|
107
108
|
port = DEFAULT_PORT,
|
|
108
109
|
taskchefVersion = TASKCHEF_VERSION,
|
|
109
110
|
serverVersion = DASHBOARD_SERVER_VERSION,
|
|
111
|
+
launcher = "mcp",
|
|
110
112
|
createServer = createDashboardServer,
|
|
111
113
|
readIdentity = readDashboardIdentity,
|
|
112
114
|
} = {}) {
|
|
@@ -117,6 +119,7 @@ export function createDashboardManager({
|
|
|
117
119
|
|
|
118
120
|
const publicResult = (action) => ({
|
|
119
121
|
action,
|
|
122
|
+
launcher,
|
|
120
123
|
url: `http://${dashboardAuthority(host, ownedServer?.port ?? port)}/`,
|
|
121
124
|
workspace: canonicalWorkspace,
|
|
122
125
|
taskchefVersion,
|
|
@@ -132,9 +135,12 @@ export function createDashboardManager({
|
|
|
132
135
|
if (listenerAbsent(error)) return false;
|
|
133
136
|
throw listenerConflict(url, `is occupied but did not return a compatible identity (${error.message}).`);
|
|
134
137
|
}
|
|
135
|
-
const expected = expectedIdentity(canonicalWorkspace, taskchefVersion, serverVersion);
|
|
138
|
+
const expected = expectedIdentity(canonicalWorkspace, taskchefVersion, serverVersion, launcher);
|
|
136
139
|
if (!isExactIdentity(identity, expected)) {
|
|
137
|
-
throw listenerConflict(
|
|
140
|
+
throw listenerConflict(
|
|
141
|
+
url,
|
|
142
|
+
"belongs to an unknown, stale, different-workspace, or differently launched service.",
|
|
143
|
+
);
|
|
138
144
|
}
|
|
139
145
|
return true;
|
|
140
146
|
};
|
|
@@ -150,6 +156,7 @@ export function createDashboardManager({
|
|
|
150
156
|
port,
|
|
151
157
|
taskchefVersion,
|
|
152
158
|
serverVersion,
|
|
159
|
+
launcher,
|
|
153
160
|
});
|
|
154
161
|
return publicResult("started");
|
|
155
162
|
} catch (error) {
|
package/src/dashboard.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from "./workspace.js";
|
|
23
23
|
import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
|
|
24
24
|
import { taskGitHubProjection } from "./dashboard/github-links.js";
|
|
25
|
+
import { createUsageTracker } from "./usage-tracker.js";
|
|
25
26
|
|
|
26
27
|
const TASKS_FILE_NAME = "tasks.jsonl";
|
|
27
28
|
const STATIC_ROOT = fileURLToPath(new URL("./dashboard/", import.meta.url));
|
|
@@ -452,8 +453,10 @@ export async function createDashboardServer({
|
|
|
452
453
|
monitorOptions = {},
|
|
453
454
|
openProject = null,
|
|
454
455
|
openThread = null,
|
|
456
|
+
launcher = "standalone",
|
|
455
457
|
taskchefVersion = TASKCHEF_VERSION,
|
|
456
458
|
serverVersion = DASHBOARD_SERVER_VERSION,
|
|
459
|
+
usageTracker = null,
|
|
457
460
|
} = {}) {
|
|
458
461
|
if (!LOOPBACK_HOSTS.has(host)) {
|
|
459
462
|
throw new Error("dashboard host must be a loopback address");
|
|
@@ -464,14 +467,19 @@ export async function createDashboardServer({
|
|
|
464
467
|
if (!Number.isInteger(maxEventClients) || maxEventClients < 0) {
|
|
465
468
|
throw new Error("dashboard event-client limit must be a non-negative integer");
|
|
466
469
|
}
|
|
470
|
+
if (!new Set(["mcp", "standalone"]).has(launcher)) {
|
|
471
|
+
throw new Error("dashboard launcher must be mcp or standalone");
|
|
472
|
+
}
|
|
467
473
|
const monitor = new DashboardMonitor(workspace, monitorOptions);
|
|
468
474
|
await monitor.start();
|
|
475
|
+
const taskUsageTracker = usageTracker ?? createUsageTracker({ workspace: monitor.workspace });
|
|
469
476
|
const identity = Object.freeze({
|
|
470
477
|
schemaVersion: 1,
|
|
471
478
|
service: "taskchef-dashboard",
|
|
472
479
|
taskchefVersion,
|
|
473
480
|
serverVersion,
|
|
474
481
|
workspace: monitor.workspace,
|
|
482
|
+
launcher,
|
|
475
483
|
});
|
|
476
484
|
if (Buffer.byteLength(`${JSON.stringify(identity)}\n`) > DASHBOARD_HEALTH_MAX_BYTES) {
|
|
477
485
|
monitor.close();
|
|
@@ -569,7 +577,16 @@ export async function createDashboardServer({
|
|
|
569
577
|
response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
|
|
570
578
|
response.end();
|
|
571
579
|
} else {
|
|
572
|
-
|
|
580
|
+
const usage = await taskUsageTracker.get(task).catch(() => ({
|
|
581
|
+
status: "unavailable",
|
|
582
|
+
reason: "Task usage is temporarily unavailable.",
|
|
583
|
+
task: null,
|
|
584
|
+
turns: {},
|
|
585
|
+
}));
|
|
586
|
+
sendJson(response, 200, {
|
|
587
|
+
schemaVersion: 1,
|
|
588
|
+
task: { ...taskDetailProjection(task), usage },
|
|
589
|
+
});
|
|
573
590
|
}
|
|
574
591
|
return;
|
|
575
592
|
}
|