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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.19.0",
3
+ "version": "7.20.0",
4
4
  "description": "Dispatch work from a data-only workspace to visible Codex project tasks.",
5
5
  "author": {
6
6
  "name": "Favo Yang",
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
- When a compatible foreground dashboard already owns port 3210,
286
- `ensure_dashboard` reuses it but does not take ownership. If an unknown,
287
- different-workspace, or stale-version process owns the port, TaskChef reports a
288
- concise conflict and never kills or replaces that process. The foreground CLI
289
- similarly asks you to stop the listener or choose another `--port`.
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, and canonical workspace. It exposes
293
- no task data, credentials, environment variables, process control, or secrets.
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
  ![TaskChef dashboard identity and version](docs/images/dashboard-identity.jpg)
296
319
 
@@ -298,6 +321,8 @@ no task data, credentials, environment variables, process control, or secrets.
298
321
 
299
322
  ![Task detail activity timeline](docs/images/result-history-dashboard.jpg)
300
323
 
324
+ ![Deferred ccusage token consumption in task details](docs/images/ccusage-token-consumption.png)
325
+
301
326
  ![Interrupted turn followed by active recovery](docs/images/interrupted-turn-recovery.jpg)
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. Exact-compatible
360
- servers may be reused; unknown listeners remain untouched.
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
 
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`, and `tasks.jsonl`
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
- When the canonical TaskChef MCP server finishes connecting, it MUST invoke the
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
- Calls MUST serialize within one MCP process. The first call starts an owned
239
- dashboard or reuses an exact compatible listener; later and concurrent calls
240
- are idempotent and report reuse after the single start. The stable default MUST
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, and canonical workspace. An unknown, malformed, different-workspace,
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; it MUST NOT close a reused
251
- external foreground server.
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, and canonical
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 after MCP connect
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 canonical workspace
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 Unknown, stale, or different workspace
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 compatible foreground `taskchef dashboard` listener may be reused
66
- but remains owned by that CLI process. No TaskChef path terminates an unknown
67
- listener or installs OS persistence.
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`, canonical
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; an unknown listener is
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.19.0",
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",
@@ -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
- item.append(header, requestLabel, request, resultLabel, result, turnMetadata);
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
- try {
468
- const response = await fetch(`/api/tasks/${encodeURIComponent(task.id)}`);
469
- if (!response.ok) throw new Error("Task details are unavailable.");
470
- const detail = await response.json();
471
- if (
472
- requestGeneration === detailRequestGeneration
473
- && state.selectedTask?.id === task.id
474
- && elements.dialog.open
475
- ) {
476
- renderDialog(detail.task);
477
- }
478
- } catch {
479
- if (state.selectedTask?.id === task.id) {
480
- showMessage("Task activity timeline is temporarily unavailable.");
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) {
@@ -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>
@@ -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; }
@@ -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(url, "belongs to an unknown, stale, or different-workspace service.");
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
- sendJson(response, 200, { schemaVersion: 1, task: taskDetailProjection(task) });
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
  }