taskchef 8.7.1 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/.codex-plugin/plugin.json +7 -10
  2. package/.mcp.json +3 -2
  3. package/README.md +31 -158
  4. package/docs/mcp-app.md +3 -3
  5. package/docs/spec.md +38 -746
  6. package/docs/taskchef-next-storage.md +2 -2
  7. package/index.js +2 -99
  8. package/package.json +12 -49
  9. package/skills/delegate/SKILL.md +44 -0
  10. package/skills/delegate/agents/openai.yaml +4 -0
  11. package/src/codex-app.js +3 -152
  12. package/src/codex-session-scanner.js +2 -2
  13. package/src/file-lock.js +25 -0
  14. package/src/mcp-app/react/NextTaskDetail.test.tsx +1 -1
  15. package/src/mcp-app/react/NextTaskDetail.tsx +8 -8
  16. package/src/mcp-app/react/main.test.tsx +3 -3
  17. package/src/mcp-app/react/main.tsx +7 -7
  18. package/src/mcp-app.js +2 -2
  19. package/src/mcp.js +4 -542
  20. package/src/next-cache.js +2 -2
  21. package/src/next-github.js +3 -3
  22. package/src/next-notifications.js +2 -2
  23. package/src/{dashboard/react → ui}/components/CardStatusIcons.tsx +1 -1
  24. package/src/{dashboard/react → ui}/components/GitHubLinks.tsx +1 -1
  25. package/src/{dashboard/react → ui}/components/LinkedText.tsx +1 -1
  26. package/src/{dashboard/react → ui}/components/RelativeTime.test.tsx +1 -1
  27. package/src/{dashboard/react → ui}/components/RelativeTime.tsx +1 -1
  28. package/src/{dashboard/react → ui}/components/ReplyImagePreview.test.tsx +1 -1
  29. package/src/{dashboard/react → ui}/components/TaskBoard.tsx +1 -1
  30. package/src/{dashboard/react → ui}/components/TaskCard.tsx +1 -1
  31. package/src/{dashboard/react → ui}/components/TurnContent.tsx +1 -1
  32. package/src/{dashboard/react → ui}/presentation.ts +1 -1
  33. package/src/version.js +0 -1
  34. package/BACKLOG.md +0 -48
  35. package/assets/e2e-benchmark-example.json +0 -50
  36. package/assets/taskchef-dispatcher-instructions.md +0 -44
  37. package/bin/taskchef.js +0 -20
  38. package/docs/advanced-guide.md +0 -497
  39. package/docs/dashboard-lifecycle.md +0 -149
  40. package/docs/dashboard-ui.md +0 -40
  41. package/docs/firstmate-taskchef-comparison.md +0 -283
  42. package/docs/images/ccusage-token-consumption.png +0 -0
  43. package/docs/images/dashboard-identity.jpg +0 -0
  44. package/docs/images/dashboard-token-pending.jpg +0 -0
  45. package/docs/images/interrupted-turn-recovery.jpg +0 -0
  46. package/docs/images/notification-event-snapshots.jpg +0 -0
  47. package/docs/images/readme-board.png +0 -0
  48. package/docs/images/readme-completed.png +0 -0
  49. package/docs/images/readme-list.png +0 -0
  50. package/docs/images/readme-needs-input.png +0 -0
  51. package/docs/images/readme-roles.png +0 -0
  52. package/docs/images/reported-work-duration-desktop.jpg +0 -0
  53. package/docs/images/reported-work-duration-mobile.jpg +0 -0
  54. package/docs/images/result-history-dashboard.jpg +0 -0
  55. package/docs/images/taskchef-pr-icon-comparison.jpg +0 -0
  56. package/docs/images/taskchef-unified-status-popup.jpg +0 -0
  57. package/docs/workflows.md +0 -476
  58. package/mcp/dashboard-session.js +0 -51
  59. package/scripts/benchmark-dispatch-prepare.js +0 -90
  60. package/scripts/e2e-benchmark.js +0 -206
  61. package/scripts/roles/resolve_roles.py +0 -293
  62. package/skills/taskchef-bootstrap/SKILL.md +0 -96
  63. package/skills/taskchef-bootstrap/agents/openai.yaml +0 -4
  64. package/skills/taskchef-bootstrap/references/project-index.md +0 -154
  65. package/skills/taskchef-copilot/SKILL.md +0 -75
  66. package/skills/taskchef-copilot/agents/openai.yaml +0 -4
  67. package/skills/taskchef-dashboard/SKILL.md +0 -24
  68. package/skills/taskchef-dashboard/agents/openai.yaml +0 -4
  69. package/skills/taskchef-delegate/SKILL.md +0 -102
  70. package/skills/taskchef-delegate/agents/openai.yaml +0 -4
  71. package/skills/taskchef-delegate/references/model-roles.md +0 -29
  72. package/skills/taskchef-executor/SKILL.md +0 -154
  73. package/skills/taskchef-executor/agents/openai.yaml +0 -4
  74. package/skills/taskchef-executor/references/compatibility.md +0 -22
  75. package/skills/taskchef-executor/references/ending-actions.md +0 -26
  76. package/src/backups.js +0 -359
  77. package/src/cli.js +0 -733
  78. package/src/config-audit.js +0 -99
  79. package/src/config-mutations.js +0 -106
  80. package/src/dashboard/actions.js +0 -136
  81. package/src/dashboard/app.js +0 -1172
  82. package/src/dashboard/dist/app.js +0 -269
  83. package/src/dashboard/dist/app.js.map +0 -1
  84. package/src/dashboard/dist/index.html +0 -16
  85. package/src/dashboard/dist/styles.css +0 -1
  86. package/src/dashboard/index.html +0 -148
  87. package/src/dashboard/react/App.test.tsx +0 -346
  88. package/src/dashboard/react/App.tsx +0 -515
  89. package/src/dashboard/react/api.test.ts +0 -40
  90. package/src/dashboard/react/api.ts +0 -103
  91. package/src/dashboard/react/components/ActivityTimeline.tsx +0 -118
  92. package/src/dashboard/react/components/ModelSettings.test.tsx +0 -180
  93. package/src/dashboard/react/components/ModelSettings.tsx +0 -106
  94. package/src/dashboard/react/components/NotificationCenter.test.tsx +0 -76
  95. package/src/dashboard/react/components/NotificationCenter.tsx +0 -111
  96. package/src/dashboard/react/components/PullRequestBadges.tsx +0 -18
  97. package/src/dashboard/react/components/TaskDetail.test.tsx +0 -255
  98. package/src/dashboard/react/components/TaskDetail.tsx +0 -271
  99. package/src/dashboard/react/components/UsagePanel.tsx +0 -96
  100. package/src/dashboard/react/index.html +0 -15
  101. package/src/dashboard/react/main.tsx +0 -12
  102. package/src/dashboard/react/overlay-state.test.ts +0 -110
  103. package/src/dashboard/react/overlay-state.ts +0 -60
  104. package/src/dashboard/react/presentation.test.tsx +0 -571
  105. package/src/dashboard/styles.css +0 -261
  106. package/src/dashboard-control.js +0 -34
  107. package/src/dashboard-manager.js +0 -339
  108. package/src/dashboard-session-process.js +0 -88
  109. package/src/dashboard-session.js +0 -86
  110. package/src/dashboard.js +0 -1303
  111. package/src/delegation.js +0 -277
  112. package/src/github.js +0 -107
  113. package/src/model-roles.js +0 -70
  114. package/src/recorded-usage-tracker.js +0 -92
  115. package/src/usage-tracker.js +0 -650
  116. package/src/usage.js +0 -367
  117. package/src/workspace-path.js +0 -48
  118. package/src/workspace.js +0 -3629
  119. /package/src/{dashboard/react → ui}/components/AnimatedUsage.test.tsx +0 -0
  120. /package/src/{dashboard/react → ui}/components/CardStatusIcons.test.tsx +0 -0
  121. /package/src/{dashboard/react → ui}/components/ImageGallery.tsx +0 -0
  122. /package/src/{dashboard/react → ui}/components/OpenChatButton.test.tsx +0 -0
  123. /package/src/{dashboard/react → ui}/components/OpenChatButton.tsx +0 -0
  124. /package/src/{dashboard/react → ui}/components/PrGlyph.tsx +0 -0
  125. /package/src/{dashboard/react → ui}/components/QueuedPrompts.test.tsx +0 -0
  126. /package/src/{dashboard/react → ui}/components/QueuedPrompts.tsx +0 -0
  127. /package/src/{dashboard/react → ui}/components/ReplyCover.test.tsx +0 -0
  128. /package/src/{dashboard/react → ui}/components/ReplyCover.tsx +0 -0
  129. /package/src/{dashboard/react → ui}/components/ReplyImagePreview.tsx +0 -0
  130. /package/src/{dashboard/react → ui}/components/ReplyMarkdown.test.tsx +0 -0
  131. /package/src/{dashboard/react → ui}/components/ReplyMarkdown.tsx +0 -0
  132. /package/src/{dashboard/react → ui}/components/SavedUsageFormat.test.tsx +0 -0
  133. /package/src/{dashboard/react → ui}/components/SavedUsageFormat.tsx +0 -0
  134. /package/src/{dashboard/react → ui}/components/ShimmerText.tsx +0 -0
  135. /package/src/{dashboard/react → ui}/components/StatsGrid.tsx +0 -0
  136. /package/src/{dashboard/react → ui}/components/StatusBadge.tsx +0 -0
  137. /package/src/{dashboard/react → ui}/components/TaskBoard.test.tsx +0 -0
  138. /package/src/{dashboard/react → ui}/components/TaskCardStats.test.tsx +0 -0
  139. /package/src/{dashboard/react → ui}/components/TaskCardStats.tsx +0 -0
  140. /package/src/{dashboard/react → ui}/components/TurnContent.test.tsx +0 -0
  141. /package/src/{dashboard/react → ui}/fixtures.ts +0 -0
  142. /package/src/{dashboard → ui}/github-links.js +0 -0
  143. /package/src/{dashboard/react → ui}/image-cache.test.ts +0 -0
  144. /package/src/{dashboard/react → ui}/image-cache.ts +0 -0
  145. /package/src/{dashboard → ui}/state.js +0 -0
  146. /package/src/{dashboard/react → ui}/styles.css +0 -0
  147. /package/src/{dashboard/react → ui}/test-setup.ts +0 -0
  148. /package/src/{dashboard → ui}/time.js +0 -0
  149. /package/src/{dashboard/react → ui}/types.ts +0 -0
package/src/dashboard.js DELETED
@@ -1,1303 +0,0 @@
1
- import { resolveModelRoles, updateModelRole } from "./model-roles.js";
2
- import { EventEmitter } from "node:events";
3
- import { randomBytes } from "node:crypto";
4
- import { constants, watch } from "node:fs";
5
- import { open, readFile, realpath, stat } from "node:fs/promises";
6
- import http from "node:http";
7
- import path from "node:path";
8
- import { fileURLToPath } from "node:url";
9
-
10
- import {
11
- archiveThreadInCodex,
12
- discoverBundledCodexCli,
13
- isCodexThreadDeepLinkId,
14
- openThreadInCodex,
15
- openWorkspaceInCodex,
16
- } from "./codex-app.js";
17
- import {
18
- acquireWorkspaceLock,
19
- canonicalDirectory,
20
- canonicalGitRoot,
21
- manuallyTransitionTask,
22
- parseTaskLogContent,
23
- createWorkspaceBackup,
24
- readConfig,
25
- readTask,
26
- } from "./workspace.js";
27
- import {
28
- DASHBOARD_SERVER_VERSION,
29
- TASKCHEF_VERSION,
30
- } from "./version.js";
31
- import { taskGitHubProjection } from "./dashboard/github-links.js";
32
- import { CODEX_CHAT_ARCHIVE_ENABLED } from "./dashboard/state.js";
33
- import { createUsageTracker } from "./usage-tracker.js";
34
- import { readUsageStore, usageStorePath } from "./usage.js";
35
- import { reportedWorkSummary } from "./reported-work.js";
36
- import {
37
- DASHBOARD_CONTROL_SESSION_PATH,
38
- DASHBOARD_CONTROL_SHUTDOWN_PATH,
39
- exactDashboardIdentity,
40
- validDashboardControlRequest,
41
- } from "./dashboard-control.js";
42
-
43
- const TASKS_FILE_NAME = "tasks.jsonl";
44
- const STATIC_ROOT = fileURLToPath(new URL("./dashboard/", import.meta.url));
45
- const DASHBOARD_BUNDLE_ROOT = path.join(STATIC_ROOT, "dist");
46
- const ASSET_ROOT = fileURLToPath(new URL("../assets/", import.meta.url));
47
- const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1"]);
48
- const DEFAULT_MAX_FILE_BYTES = 16 * 1024 * 1024;
49
- const DEFAULT_MAX_TASKS = 2_000;
50
- const DEFAULT_MAX_EVENT_CLIENTS = 16;
51
- const DEFAULT_CALCULATING_USAGE_STALE_MS = 120_000;
52
- const MAX_MANUAL_TRANSITION_BODY_BYTES = 4 * 1024;
53
- export const DASHBOARD_HEALTH_PATH = "/api/health";
54
- export const DASHBOARD_HEALTH_MAX_BYTES = 8 * 1024;
55
- function contentSecurityPolicy(styleNonce = null) {
56
- return [
57
- "default-src 'self'",
58
- "base-uri 'none'",
59
- "connect-src 'self'",
60
- "form-action 'none'",
61
- "frame-ancestors 'none'",
62
- "img-src 'self' data:",
63
- "object-src 'none'",
64
- "script-src 'self'",
65
- `style-src 'self'${styleNonce ? ` 'nonce-${styleNonce}'` : ""}`,
66
- // Mantine uses style attributes for component variables and dynamic positioning.
67
- "style-src-attr 'unsafe-inline'",
68
- ].join("; ");
69
- }
70
-
71
- const STATIC_FILES = new Map([
72
- ["/", [path.join(DASHBOARD_BUNDLE_ROOT, "index.html"), "text/html; charset=utf-8"]],
73
- ["/actions.js", [path.join(STATIC_ROOT, "actions.js"), "text/javascript; charset=utf-8"]],
74
- ["/app.js", [path.join(DASHBOARD_BUNDLE_ROOT, "app.js"), "text/javascript; charset=utf-8"]],
75
- ["/github-links.js", [path.join(STATIC_ROOT, "github-links.js"), "text/javascript; charset=utf-8"]],
76
- ["/time.js", [path.join(STATIC_ROOT, "time.js"), "text/javascript; charset=utf-8"]],
77
- ["/state.js", [path.join(STATIC_ROOT, "state.js"), "text/javascript; charset=utf-8"]],
78
- ["/styles.css", [path.join(DASHBOARD_BUNDLE_ROOT, "styles.css"), "text/css; charset=utf-8"]],
79
- ["/assets/taskchef-dark.svg", [path.join(ASSET_ROOT, "taskchef-dark.svg"), "image/svg+xml"]],
80
- ["/assets/taskchef.svg", [path.join(ASSET_ROOT, "taskchef.svg"), "image/svg+xml"]],
81
- ["/assets/codex-app-dark.png", [path.join(ASSET_ROOT, "codex-app-dark.png"), "image/png"]],
82
- ["/assets/codex-app-light.png", [path.join(ASSET_ROOT, "codex-app-light.png"), "image/png"]],
83
- ]);
84
-
85
- export function dashboardAuthority(host, port) {
86
- const address = host === "::1" ? "[::1]" : host;
87
- return port === 80 ? address : `${address}:${port}`;
88
- }
89
-
90
- function meaningfulUpdateTime(task, observedUpdateTimes) {
91
- return Math.max(
92
- Date.parse(task.updatedAt ?? task.createdAt),
93
- observedUpdateTimes.get(task.id) ?? Number.NEGATIVE_INFINITY,
94
- );
95
- }
96
-
97
- export function sortTasksByMeaningfulUpdate(tasks, observedUpdateTimes = new Map()) {
98
- return tasks
99
- .map((task, index) => ({ task, index }))
100
- .sort((left, right) =>
101
- meaningfulUpdateTime(right.task, observedUpdateTimes)
102
- - meaningfulUpdateTime(left.task, observedUpdateTimes)
103
- || left.index - right.index)
104
- .map(({ task }) => task);
105
- }
106
-
107
- function taskFingerprint(tasks) {
108
- return JSON.stringify(tasks);
109
- }
110
-
111
- async function fileFingerprint(filePath) {
112
- const details = await stat(filePath);
113
- return statFingerprint(details);
114
- }
115
-
116
- function statFingerprint(details) {
117
- return `${details.dev}:${details.ino}:${details.size}:${details.mtimeMs}`;
118
- }
119
-
120
- export async function readBoundedTaskLog(filePath, maximumBytes, { afterOpen = null } = {}) {
121
- const flags = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0);
122
- const handle = await open(filePath, flags);
123
- try {
124
- const before = await handle.stat();
125
- if (!before.isFile()) throw new Error("task log is not a regular file");
126
- if (afterOpen) await afterOpen();
127
- const chunks = [];
128
- let total = 0;
129
- while (total <= maximumBytes) {
130
- const buffer = Buffer.alloc(Math.min(64 * 1024, maximumBytes + 1 - total));
131
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
132
- if (bytesRead === 0) break;
133
- chunks.push(buffer.subarray(0, bytesRead));
134
- total += bytesRead;
135
- }
136
- if (total > maximumBytes) {
137
- throw new Error(`task log exceeds the dashboard limit of ${maximumBytes} bytes`);
138
- }
139
- const after = await handle.stat();
140
- if (statFingerprint(before) !== statFingerprint(after)) {
141
- throw new Error("task log changed while the dashboard was reading it");
142
- }
143
- return {
144
- content: Buffer.concat(chunks, total).toString("utf8"),
145
- fingerprint: statFingerprint(after),
146
- };
147
- } finally {
148
- await handle.close();
149
- }
150
- }
151
-
152
- function boundedText(value, maximum, name) {
153
- if (value !== null && value !== undefined && String(value).length > maximum) {
154
- throw new Error(`${name} exceeds the dashboard display limit`);
155
- }
156
- }
157
-
158
- function assertDashboardTaskBounds(tasks, maximumTasks) {
159
- if (tasks.length > maximumTasks) {
160
- throw new Error(`task log exceeds the dashboard limit of ${maximumTasks} tasks`);
161
- }
162
- for (const [index, task] of tasks.entries()) {
163
- const name = `task ${index + 1}`;
164
- boundedText(task.id, 512, `${name} ID`);
165
- boundedText(task.title, 1_000, `${name} title`);
166
- boundedText(task.instruction, 250_000, `${name} instruction`);
167
- boundedText(task.summary, 2_000, `${name} summary`);
168
- boundedText(task.threadId, 512, `${name} thread ID`);
169
- boundedText(task.turnRef, 512, `${name} turn ref`);
170
- boundedText(task.turnId, 512, `${name} turn ID`);
171
- boundedText(task.lastResult?.summary, 2_000, `${name} last result summary`);
172
- boundedText(task.lastResult?.turnRef, 512, `${name} last result turn ref`);
173
- boundedText(task.lastResult?.turnId, 512, `${name} last result turn ID`);
174
- boundedText(task.latestTurn?.requestSummary, 1_000, `${name} latest request summary`);
175
- boundedText(task.latestTurn?.turnRef, 512, `${name} latest turn ref`);
176
- boundedText(task.latestTurn?.turnId, 512, `${name} latest turn ID`);
177
- const turns = task.turns ?? [];
178
- if (turns.length > 10_000) {
179
- throw new Error(`${name} has too many turns for the dashboard`);
180
- }
181
- for (const [turnIndex, turn] of turns.entries()) {
182
- boundedText(turn.requestSummary, 1_000, `${name} turn ${turnIndex + 1} request summary`);
183
- boundedText(turn.turnRef, 512, `${name} turn ${turnIndex + 1} turn ref`);
184
- boundedText(turn.turnId, 512, `${name} turn ${turnIndex + 1} turn ID`);
185
- boundedText(turn.result?.summary, 2_000, `${name} turn ${turnIndex + 1} result summary`);
186
- boundedText(
187
- turn.provenance?.actionId,
188
- 512,
189
- `${name} turn ${turnIndex + 1} manual action ID`,
190
- );
191
- }
192
- const results = task.results ?? [];
193
- if (results.length > 10_000) {
194
- throw new Error(`${name} has too many results for the dashboard`);
195
- }
196
- for (const [resultIndex, result] of results.entries()) {
197
- boundedText(result.summary, 2_000, `${name} result ${resultIndex + 1} summary`);
198
- boundedText(result.turnRef, 512, `${name} result ${resultIndex + 1} turn ref`);
199
- boundedText(result.turnId, 512, `${name} result ${resultIndex + 1} turn ID`);
200
- boundedText(
201
- result.provenance?.actionId,
202
- 512,
203
- `${name} result ${resultIndex + 1} manual action ID`,
204
- );
205
- }
206
- boundedText(task.project.name, 1_000, `${name} project name`);
207
- boundedText(task.project.path, 8_192, `${name} project path`);
208
- boundedText(task.project.description, 4_000, `${name} project description`);
209
- if (task.project.githubRepos.length > 100) {
210
- throw new Error(`${name} has too many GitHub repositories for the dashboard`);
211
- }
212
- for (const repository of task.project.githubRepos) {
213
- boundedText(repository, 2_048, `${name} GitHub repository`);
214
- }
215
- }
216
- }
217
-
218
- function taskListProjection(task) {
219
- const { turns: _turns, results: _results, ...projection } = {
220
- ...task,
221
- ...taskGitHubProjection(task),
222
- reportedWork: reportedWorkSummary(task.turns ?? []),
223
- };
224
- return projection;
225
- }
226
-
227
- function taskDetailProjection(task) {
228
- return { ...task, ...taskGitHubProjection(task) };
229
- }
230
-
231
- function cachedUsageSummary(record) {
232
- if (!record) return null;
233
- return Object.freeze({
234
- generationTurnRef: record.generationTurnRef ?? null,
235
- status: record.status,
236
- updatedAt: record.updatedAt ?? null,
237
- ...(record.reason ? { reason: record.reason } : {}),
238
- ...(record.task ? {
239
- task: Object.freeze({
240
- totalTokens: record.task.totalTokens,
241
- ...(record.task.partial === true ? { partial: true } : {}),
242
- ...(record.task.costPartial === true ? { costPartial: true } : {}),
243
- estimatedCostUsd: record.task.estimatedCostUsd ?? null,
244
- sampledAt: record.task.sampledAt ?? null,
245
- sourceUpdatedAt: record.task.sourceUpdatedAt ?? null,
246
- }),
247
- } : {}),
248
- });
249
- }
250
-
251
- function cachedUsageRecord(record) {
252
- return Object.freeze({
253
- threadId: record.threadId,
254
- ...cachedUsageSummary(record),
255
- });
256
- }
257
-
258
- function calculatingUsageIsStale(cached, now, staleMs) {
259
- if (cached?.status !== "calculating") return false;
260
- const updatedAt = Date.parse(cached.updatedAt ?? "");
261
- return !Number.isFinite(updatedAt) || updatedAt + staleMs <= now;
262
- }
263
-
264
- export function taskListUsageProjection(task, cached, {
265
- now = Date.now(),
266
- calculatingStaleMs = DEFAULT_CALCULATING_USAGE_STALE_MS,
267
- } = {}) {
268
- if (!task.threadId) {
269
- return { status: "unavailable", reason: "Token usage unavailable" };
270
- }
271
- if (!task.latestTurn) {
272
- return { status: "unavailable", reason: "Token usage unavailable" };
273
- }
274
- if (!cached || cached.threadId !== task.threadId) {
275
- return task.status === "working"
276
- ? { generationTurnRef: task.latestTurn.turnRef ?? null, status: "pending" }
277
- : { generationTurnRef: task.latestTurn.turnRef ?? null, status: "unavailable", reason: "Token usage unavailable" };
278
- }
279
- const latestTurnRef = task.latestTurn.turnRef ?? task.latestTurn.turnId ?? null;
280
- if (task.status !== "working" && cached.generationTurnRef !== latestTurnRef) {
281
- return {
282
- generationTurnRef: latestTurnRef,
283
- status: "unavailable",
284
- reason: "No cached usage boundary is available for this task state.",
285
- };
286
- }
287
- if (calculatingUsageIsStale(cached, now, calculatingStaleMs)) {
288
- return {
289
- ...cachedUsageSummary(cached),
290
- generationTurnRef: cached.generationTurnRef ?? latestTurnRef,
291
- status: "unavailable",
292
- reason: "Token usage calculation was interrupted.",
293
- };
294
- }
295
- return cachedUsageSummary(cached);
296
- }
297
-
298
- async function optionalFileFingerprint(filePath) {
299
- return fileFingerprint(filePath).catch((error) => {
300
- if (error?.code === "ENOENT") return "missing";
301
- throw error;
302
- });
303
- }
304
-
305
- export class UsageSummaryMonitor extends EventEmitter {
306
- constructor(workspace, {
307
- calculatingStaleMs = DEFAULT_CALCULATING_USAGE_STALE_MS,
308
- fingerprint = optionalFileFingerprint,
309
- now = Date.now,
310
- pollIntervalMs = 5_000,
311
- readStore = readUsageStore,
312
- watchDirectory = watch,
313
- } = {}) {
314
- super();
315
- this.workspace = workspace;
316
- this.calculatingStaleMs = calculatingStaleMs;
317
- this.fingerprint = fingerprint;
318
- this.now = now;
319
- this.pollIntervalMs = pollIntervalMs;
320
- this.readStore = readStore;
321
- this.watchDirectory = watchDirectory;
322
- this.currentFingerprint = null;
323
- this.records = new Map();
324
- this.started = false;
325
- this.refreshPromise = null;
326
- this.refreshQueued = false;
327
- this.watcher = null;
328
- this.pollTimer = null;
329
- this.debounceTimer = null;
330
- this.lifecycleFingerprint = "";
331
- }
332
-
333
- async start() {
334
- if (this.started) return;
335
- this.workspace = await realpath(path.resolve(this.workspace));
336
- this.file = usageStorePath(this.workspace);
337
- await this.refresh({ force: true });
338
- this.started = true;
339
- try {
340
- this.watcher = this.watchDirectory(this.workspace, { persistent: false }, (_event, fileName) => {
341
- if (fileName !== null && String(fileName) !== path.basename(this.file)) return;
342
- clearTimeout(this.debounceTimer);
343
- this.debounceTimer = setTimeout(() => {
344
- void this.refresh({ force: true }).catch(() => {});
345
- }, 75);
346
- this.debounceTimer.unref?.();
347
- });
348
- this.watcher.on("error", () => {
349
- this.watcher?.close();
350
- this.watcher = null;
351
- });
352
- } catch {
353
- this.watcher = null;
354
- }
355
- this.pollTimer = setInterval(() => {
356
- void this.refresh().catch(() => {});
357
- }, this.pollIntervalMs);
358
- this.pollTimer.unref?.();
359
- }
360
-
361
- project(task) {
362
- return taskListUsageProjection(task, this.records.get(task.id) ?? null, {
363
- calculatingStaleMs: this.calculatingStaleMs,
364
- now: this.now(),
365
- });
366
- }
367
-
368
- updateLifecycleFingerprint() {
369
- const timestamp = this.now();
370
- const next = JSON.stringify([...this.records].flatMap(([taskId, record]) => (
371
- calculatingUsageIsStale(record, timestamp, this.calculatingStaleMs) ? [taskId] : []
372
- )));
373
- const changed = next !== this.lifecycleFingerprint;
374
- this.lifecycleFingerprint = next;
375
- return changed;
376
- }
377
-
378
- async refresh({ force = false } = {}) {
379
- if (this.refreshPromise) {
380
- this.refreshQueued ||= force;
381
- return this.refreshPromise;
382
- }
383
- this.refreshPromise = this.refreshOnce({ force }).finally(async () => {
384
- this.refreshPromise = null;
385
- if (this.refreshQueued) {
386
- this.refreshQueued = false;
387
- await this.refresh({ force: true });
388
- }
389
- });
390
- return this.refreshPromise;
391
- }
392
-
393
- async refreshOnce({ force }) {
394
- const observed = await this.fingerprint(this.file);
395
- if (!force && observed === this.currentFingerprint) {
396
- const changed = this.updateLifecycleFingerprint();
397
- if (changed) this.emit("change");
398
- return changed;
399
- }
400
- let store;
401
- try {
402
- store = await this.readStore(this.workspace);
403
- } catch {
404
- store = { tasks: {} };
405
- }
406
- const after = await this.fingerprint(this.file);
407
- if (observed !== after) {
408
- this.refreshQueued = true;
409
- return false;
410
- }
411
- const records = new Map(Object.entries(store.tasks ?? {}).map(
412
- ([taskId, record]) => [taskId, cachedUsageRecord(record)],
413
- ));
414
- const recordsChanged = JSON.stringify([...records]) !== JSON.stringify([...this.records]);
415
- this.records = records;
416
- this.currentFingerprint = after;
417
- const lifecycleChanged = this.updateLifecycleFingerprint();
418
- const changed = recordsChanged || lifecycleChanged;
419
- if (changed) this.emit("change");
420
- return changed;
421
- }
422
-
423
- close() {
424
- this.started = false;
425
- this.watcher?.close();
426
- clearTimeout(this.debounceTimer);
427
- clearInterval(this.pollTimer);
428
- this.watcher = null;
429
- }
430
- }
431
-
432
- export class DashboardMonitor extends EventEmitter {
433
- constructor(workspace, {
434
- debounceMs = 75,
435
- fingerprint = fileFingerprint,
436
- maxFileBytes = DEFAULT_MAX_FILE_BYTES,
437
- maxTasks = DEFAULT_MAX_TASKS,
438
- readSnapshot = null,
439
- pollIntervalMs = 5_000,
440
- watchDirectory = watch,
441
- watchReconnectMs = 1_000,
442
- } = {}) {
443
- super();
444
- this.workspace = workspace;
445
- this.debounceMs = debounceMs;
446
- this.pollIntervalMs = pollIntervalMs;
447
- this.watchDirectory = watchDirectory;
448
- this.watchReconnectMs = watchReconnectMs;
449
- this.fingerprint = fingerprint;
450
- this.maxFileBytes = maxFileBytes;
451
- this.maxTasks = maxTasks;
452
- this.readSnapshot = readSnapshot ?? (async () => {
453
- const snapshot = await readBoundedTaskLog(this.tasksFile, this.maxFileBytes);
454
- return {
455
- fingerprint: snapshot.fingerprint,
456
- tasks: await parseTaskLogContent(this.workspace, snapshot.content, {
457
- validateWorkspace: false,
458
- }),
459
- };
460
- });
461
- this.tasks = [];
462
- this.revision = 0;
463
- this.started = false;
464
- this.currentFingerprint = null;
465
- this.currentTaskFingerprint = null;
466
- this.unhealthy = false;
467
- this.projectIndex = { status: "available", projectCount: 0, missingProjects: [] };
468
- this.observedUpdateTimes = new Map();
469
- this.refreshPromise = null;
470
- this.refreshQueued = false;
471
- this.watcher = null;
472
- this.debounceTimer = null;
473
- this.pollTimer = null;
474
- this.reconnectTimer = null;
475
- }
476
-
477
- snapshot() {
478
- return {
479
- schemaVersion: 1,
480
- revision: this.revision,
481
- generatedAt: new Date().toISOString(),
482
- healthy: !this.unhealthy,
483
- projectIndex: this.projectIndex,
484
- tasks: this.tasks.map((task) => ({
485
- ...taskListProjection(task),
486
- meaningfulUpdatedAt: new Date(
487
- meaningfulUpdateTime(task, this.observedUpdateTimes),
488
- ).toISOString(),
489
- })),
490
- };
491
- }
492
-
493
- async start() {
494
- if (this.started) return this.snapshot();
495
- this.workspace = await realpath(path.resolve(this.workspace));
496
- this.tasksFile = path.join(this.workspace, TASKS_FILE_NAME);
497
- await this.refresh({ force: true });
498
- if (this.revision === 0) throw new Error("dashboard could not read a valid task log");
499
- this.started = true;
500
- this.startWatcher();
501
- this.pollTimer = setInterval(() => this.refresh(), this.pollIntervalMs);
502
- this.pollTimer.unref?.();
503
- return this.snapshot();
504
- }
505
-
506
- startWatcher() {
507
- if (!this.started || this.watcher) return;
508
- try {
509
- this.watcher = this.watchDirectory(this.workspace, { persistent: false }, (_event, fileName) => {
510
- if (fileName !== null && !new Set([TASKS_FILE_NAME, "taskchef.json", "config-audit.jsonl"]).has(String(fileName))) return;
511
- clearTimeout(this.debounceTimer);
512
- this.debounceTimer = setTimeout(() => this.refresh({ force: true }), this.debounceMs);
513
- this.debounceTimer.unref?.();
514
- });
515
- this.watcher.on("error", (error) => {
516
- this.emit("watcherError", error);
517
- this.watcher?.close();
518
- this.watcher = null;
519
- this.scheduleWatcherReconnect();
520
- });
521
- } catch (error) {
522
- this.emit("watcherError", error);
523
- this.scheduleWatcherReconnect();
524
- }
525
- }
526
-
527
- scheduleWatcherReconnect() {
528
- if (!this.started || this.reconnectTimer) return;
529
- this.reconnectTimer = setTimeout(() => {
530
- this.reconnectTimer = null;
531
- this.startWatcher();
532
- this.refresh({ force: true });
533
- }, this.watchReconnectMs);
534
- this.reconnectTimer.unref?.();
535
- }
536
-
537
- async refresh({ force = false } = {}) {
538
- if (this.refreshPromise) {
539
- this.refreshQueued ||= force;
540
- return this.refreshPromise;
541
- }
542
- this.refreshPromise = this.refreshOnce({ force }).finally(async () => {
543
- this.refreshPromise = null;
544
- if (this.refreshQueued) {
545
- this.refreshQueued = false;
546
- await this.refresh({ force: true });
547
- }
548
- });
549
- return this.refreshPromise;
550
- }
551
-
552
- async refreshOnce({ force }) {
553
- try {
554
- const observed = await this.fingerprint(this.tasksFile);
555
- if (!force && observed === this.currentFingerprint) return false;
556
- const snapshot = await this.readSnapshot();
557
- assertDashboardTaskBounds(snapshot.tasks, this.maxTasks);
558
- const after = await this.fingerprint(this.tasksFile);
559
- if (snapshot.fingerprint !== after) {
560
- this.refreshQueued = true;
561
- return false;
562
- }
563
- const previous = new Map(this.tasks.map((task) => [task.id, task]));
564
- const currentIds = new Set(snapshot.tasks.map((task) => task.id));
565
- for (const taskId of this.observedUpdateTimes.keys()) {
566
- if (!currentIds.has(taskId)) this.observedUpdateTimes.delete(taskId);
567
- }
568
- const observedAt = Date.now();
569
- for (const task of snapshot.tasks) {
570
- const former = previous.get(task.id);
571
- if (former && JSON.stringify(former) !== JSON.stringify(task)) {
572
- this.observedUpdateTimes.set(task.id, observedAt);
573
- }
574
- }
575
- const tasks = sortTasksByMeaningfulUpdate(snapshot.tasks, this.observedUpdateTimes);
576
- let projectIndex;
577
- try {
578
- const config = await readConfig(this.workspace, { checkPaths: false });
579
- const configured = new Set(config.projects.map((project) => project.path));
580
- const missing = new Map();
581
- for (const task of tasks) {
582
- if (configured.has(task.project.path)) continue;
583
- const item = missing.get(task.project.path) ?? {
584
- path: task.project.path, snapshotNames: new Set(), taskCount: 0,
585
- };
586
- item.snapshotNames.add(task.project.name);
587
- item.taskCount += 1;
588
- missing.set(task.project.path, item);
589
- }
590
- projectIndex = {
591
- status: "available",
592
- projectCount: config.projects.length,
593
- missingProjects: [...missing.values()].slice(0, 50).map((item) => ({
594
- ...item, snapshotNames: [...item.snapshotNames].sort(),
595
- })),
596
- truncated: missing.size > 50,
597
- };
598
- } catch {
599
- projectIndex = { status: "unavailable", projectCount: null, missingProjects: [] };
600
- }
601
- const nextTaskFingerprint = taskFingerprint({ tasks, projectIndex });
602
- this.currentFingerprint = after;
603
- this.projectIndex = projectIndex;
604
- if (nextTaskFingerprint === this.currentTaskFingerprint) {
605
- if (this.unhealthy) {
606
- this.unhealthy = false;
607
- this.emit("snapshot", this.snapshot());
608
- }
609
- return false;
610
- }
611
- this.tasks = tasks;
612
- this.currentTaskFingerprint = nextTaskFingerprint;
613
- this.revision += 1;
614
- this.unhealthy = false;
615
- this.emit("snapshot", this.snapshot());
616
- return true;
617
- } catch (error) {
618
- if (!this.unhealthy) this.emit("monitorError", error);
619
- this.unhealthy = true;
620
- return false;
621
- }
622
- }
623
-
624
- close() {
625
- this.started = false;
626
- this.watcher?.close();
627
- clearTimeout(this.debounceTimer);
628
- clearTimeout(this.reconnectTimer);
629
- clearInterval(this.pollTimer);
630
- this.watcher = null;
631
- }
632
- }
633
-
634
- function securityHeaders(contentType, { styleNonce = null } = {}) {
635
- return {
636
- "Cache-Control": "no-store",
637
- "Content-Security-Policy": contentSecurityPolicy(styleNonce),
638
- "Content-Type": contentType,
639
- "Cross-Origin-Opener-Policy": "same-origin",
640
- "Referrer-Policy": "no-referrer",
641
- "X-Content-Type-Options": "nosniff",
642
- "X-Frame-Options": "DENY",
643
- };
644
- }
645
-
646
- function sendJson(response, status, value) {
647
- response.writeHead(status, securityHeaders("application/json; charset=utf-8"));
648
- response.end(`${JSON.stringify(value)}\n`);
649
- }
650
-
651
- async function readBoundedJsonBody(request, maximumBytes = MAX_MANUAL_TRANSITION_BODY_BYTES) {
652
- const contentType = request.headers["content-type"] ?? "";
653
- if (!/^application\/json(?:\s*;|$)/i.test(contentType)) {
654
- const error = new Error("Request content type must be application/json.");
655
- error.code = "unsupported_media_type";
656
- throw error;
657
- }
658
- const chunks = [];
659
- let total = 0;
660
- for await (const chunk of request) {
661
- total += chunk.length;
662
- if (total > maximumBytes) {
663
- const error = new Error("Request body is too large.");
664
- error.code = "body_too_large";
665
- throw error;
666
- }
667
- chunks.push(chunk);
668
- }
669
- let value;
670
- try {
671
- value = JSON.parse(Buffer.concat(chunks, total).toString("utf8"));
672
- } catch {
673
- const error = new Error("Request body must be valid JSON.");
674
- error.code = "malformed_json";
675
- throw error;
676
- }
677
- if (!value || typeof value !== "object" || Array.isArray(value)) {
678
- const error = new Error("Request body must be a JSON object.");
679
- error.code = "invalid_request";
680
- throw error;
681
- }
682
- return value;
683
- }
684
-
685
- function manualTransitionInput(body) {
686
- const fields = new Set(["schemaVersion", "actionId", "expected", "targetStatus"]);
687
- const unexpected = Object.keys(body).find((key) => !fields.has(key));
688
- const missing = [...fields].find((key) => !(key in body));
689
- if (unexpected || missing || body.schemaVersion !== 1) {
690
- const error = new Error("Manual transition request has an invalid shape.");
691
- error.code = "invalid_request";
692
- throw error;
693
- }
694
- const { schemaVersion: _schemaVersion, ...input } = body;
695
- return input;
696
- }
697
-
698
- function manualTransitionErrorResponse(error) {
699
- const definitions = new Map([
700
- ["malformed_json", [400, "Request body must be valid JSON."]],
701
- ["invalid_request", [400, "Manual transition request is invalid."]],
702
- ["task_not_found", [404, "Task not found."]],
703
- ["stale_task", [409, "This task changed. Review its current state and try again."]],
704
- ["invalid_transition", [409, "This task can no longer be changed manually."]],
705
- ["idempotency_conflict", [409, "This manual action ID was already used."]],
706
- ["body_too_large", [413, "Request body is too large."]],
707
- ["unsupported_media_type", [415, "Request content type must be application/json."]],
708
- ["ELOCKED", [503, "TaskChef is busy updating the workspace. Try again."]],
709
- ]);
710
- const definition = definitions.get(error?.code);
711
- const [status, message] = definition ?? [500, "Dashboard request failed."];
712
- return {
713
- status,
714
- body: {
715
- code: error?.code === "ELOCKED"
716
- ? "workspace_busy"
717
- : (definition ? error.code : "dashboard_error"),
718
- message,
719
- ...(error?.task ? { task: taskDetailProjection(error.task) } : {}),
720
- },
721
- };
722
- }
723
-
724
- function ssePayload(event, value) {
725
- return `event: ${event}\ndata: ${JSON.stringify(value)}\n\n`;
726
- }
727
-
728
- export function writeSseEvent(response, event, value) {
729
- return response.write(ssePayload(event, value));
730
- }
731
-
732
- export function createSseClient(response, {
733
- drainTimeoutMs = 5_000,
734
- onClose = () => {},
735
- } = {}) {
736
- let blocked = false;
737
- let closed = false;
738
- let drainTimer = null;
739
- let queuedPayload = null;
740
- const drained = () => {
741
- blocked = false;
742
- clearTimeout(drainTimer);
743
- drainTimer = null;
744
- const payload = queuedPayload;
745
- queuedPayload = null;
746
- if (payload !== null) client.write(payload);
747
- };
748
- const client = {
749
- get blocked() { return blocked; },
750
- write(payload) {
751
- if (closed) return false;
752
- if (blocked) {
753
- queuedPayload = payload;
754
- return false;
755
- }
756
- const accepted = response.write(payload);
757
- if (!accepted) {
758
- blocked = true;
759
- response.once("drain", drained);
760
- drainTimer = setTimeout(() => client.close(), drainTimeoutMs);
761
- drainTimer.unref?.();
762
- }
763
- return accepted;
764
- },
765
- close() {
766
- if (closed) return;
767
- closed = true;
768
- clearTimeout(drainTimer);
769
- queuedPayload = null;
770
- response.off("drain", drained);
771
- response.destroy();
772
- onClose();
773
- },
774
- };
775
- return client;
776
- }
777
-
778
- function publicMonitorError() {
779
- return {
780
- message: "The task log is temporarily unavailable. Showing the last valid snapshot.",
781
- };
782
- }
783
-
784
- export async function createDashboardServer({
785
- archiveEnabled = CODEX_CHAT_ARCHIVE_ENABLED,
786
- archiveThread = archiveThreadInCodex,
787
- discoverArchiveCli = discoverBundledCodexCli,
788
- workspace,
789
- host = "127.0.0.1",
790
- maxEventClients = DEFAULT_MAX_EVENT_CLIENTS,
791
- port = 3210,
792
- monitorOptions = {},
793
- openProject = null,
794
- openThread = null,
795
- launcher = "standalone",
796
- taskchefVersion = TASKCHEF_VERSION,
797
- serverVersion = DASHBOARD_SERVER_VERSION,
798
- usageTracker = null,
799
- usageSummaryMonitor = null,
800
- usagePreloadIntervalMs = 30_000,
801
- resolveRoles = resolveModelRoles,
802
- updateRole = updateModelRole,
803
- control = null,
804
- } = {}) {
805
- if (!LOOPBACK_HOSTS.has(host)) {
806
- throw new Error("dashboard host must be a loopback address");
807
- }
808
- if (!Number.isInteger(port) || port < 0 || port > 65_535) {
809
- throw new Error("dashboard port must be an integer from 0 to 65535");
810
- }
811
- if (!Number.isInteger(maxEventClients) || maxEventClients < 0) {
812
- throw new Error("dashboard event-client limit must be a non-negative integer");
813
- }
814
- if (!Number.isFinite(usagePreloadIntervalMs) || usagePreloadIntervalMs < 1_000) {
815
- throw new Error("dashboard usage preload interval must be at least one second");
816
- }
817
- if (!new Set(["mcp", "session", "standalone"]).has(launcher)) {
818
- throw new Error("dashboard launcher must be mcp, session, or standalone");
819
- }
820
- if (control !== null && (!new Set(["mcp", "session"]).has(launcher)
821
- || typeof control?.onShutdown !== "function"
822
- || (launcher === "session" && typeof control?.onSession !== "function"))) {
823
- throw new Error("dashboard control requires TaskChef session callbacks");
824
- }
825
- let requestHandler = (_request, response) => {
826
- sendJson(response, 503, { message: "TaskChef dashboard is initializing." });
827
- };
828
- const server = http.createServer((request, response) => requestHandler(request, response));
829
- await new Promise((resolve, reject) => {
830
- server.once("error", reject);
831
- server.listen(port, host, resolve);
832
- });
833
- const address = server.address();
834
- const boundPort = typeof address === "object" && address ? address.port : port;
835
- const allowedAuthority = dashboardAuthority(host, boundPort);
836
- const allowedOrigin = `http://${allowedAuthority}`;
837
- const monitor = new DashboardMonitor(workspace, monitorOptions);
838
- let taskUsageTracker;
839
- let taskUsageSummaryMonitor;
840
- let identity;
841
- try {
842
- await createWorkspaceBackup(workspace, { reason: `dashboard-${launcher}-startup` });
843
- await monitor.start();
844
- taskUsageTracker = usageTracker ?? createUsageTracker({ workspace: monitor.workspace });
845
- taskUsageSummaryMonitor = usageSummaryMonitor
846
- ?? new UsageSummaryMonitor(monitor.workspace);
847
- await taskUsageSummaryMonitor.start();
848
- identity = Object.freeze({
849
- schemaVersion: 1,
850
- service: "taskchef-dashboard",
851
- taskchefVersion,
852
- serverVersion,
853
- workspace: monitor.workspace,
854
- launcher,
855
- });
856
- if (Buffer.byteLength(`${JSON.stringify(identity)}\n`) > DASHBOARD_HEALTH_MAX_BYTES) {
857
- throw new Error("dashboard identity exceeds the health response limit");
858
- }
859
- } catch (error) {
860
- taskUsageSummaryMonitor?.close();
861
- taskUsageTracker?.close?.();
862
- monitor.close();
863
- await new Promise((resolve) => server.close(() => resolve()));
864
- throw error;
865
- }
866
- const clients = new Set();
867
- const archiveRequests = new Set();
868
- const broadcast = (event, value) => {
869
- const payload = ssePayload(event, value);
870
- for (const client of clients) client.write(payload);
871
- };
872
- const dashboardSnapshot = () => {
873
- const snapshot = monitor.snapshot();
874
- return {
875
- ...snapshot,
876
- tasks: snapshot.tasks.map((task) => ({
877
- ...task,
878
- usage: taskUsageSummaryMonitor.project(task),
879
- })),
880
- };
881
- };
882
- const preloadUsage = () => {
883
- void Promise.resolve(taskUsageTracker.preload?.(monitor.tasks)).catch(() => {});
884
- };
885
- const snapshotListener = () => {
886
- preloadUsage();
887
- broadcast("snapshot", dashboardSnapshot());
888
- };
889
- const usageSummaryListener = () => broadcast("snapshot", dashboardSnapshot());
890
- const errorListener = () => broadcast("dashboard-error", publicMonitorError());
891
- monitor.on("snapshot", snapshotListener);
892
- monitor.on("monitorError", errorListener);
893
- taskUsageSummaryMonitor.on("change", usageSummaryListener);
894
- preloadUsage();
895
- const usagePreloadTimer = setInterval(preloadUsage, usagePreloadIntervalMs);
896
- usagePreloadTimer.unref?.();
897
-
898
- const handleRequest = async (request, response) => {
899
- const method = request.method ?? "GET";
900
- let url;
901
- try {
902
- url = new URL(request.url ?? "/", "http://localhost");
903
- } catch {
904
- sendJson(response, 400, { message: "Malformed request target." });
905
- return;
906
- }
907
- if (request.headers.host !== allowedAuthority) {
908
- sendJson(response, 421, { message: "Misdirected request." });
909
- return;
910
- }
911
- if (method !== "GET" && method !== "HEAD" && method !== "POST") {
912
- response.writeHead(405, { Allow: "GET, HEAD, POST" });
913
- response.end();
914
- return;
915
- }
916
-
917
- if (url.pathname === DASHBOARD_HEALTH_PATH && (method === "GET" || method === "HEAD")) {
918
- if (method === "HEAD") {
919
- response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
920
- response.end();
921
- } else {
922
- sendJson(response, 200, identity);
923
- }
924
- return;
925
- }
926
-
927
- if (url.pathname === DASHBOARD_CONTROL_SHUTDOWN_PATH && method === "POST") {
928
- if (!control) {
929
- sendJson(response, 404, { message: "Not found." });
930
- return;
931
- }
932
- if (!validDashboardControlRequest(request, {
933
- authority: allowedAuthority, origin: allowedOrigin, action: "shutdown",
934
- })) {
935
- sendJson(response, 403, { message: "Dashboard control request was refused." });
936
- return;
937
- }
938
- const body = await readBoundedJsonBody(request);
939
- if (!body || Object.keys(body).length !== 2 || body.reason !== "version-replacement"
940
- || !("expectedIdentity" in body)) {
941
- sendJson(response, 400, { message: "Dashboard shutdown request was malformed." });
942
- return;
943
- }
944
- if (!exactDashboardIdentity(identity, body.expectedIdentity)) {
945
- sendJson(response, 409, { schemaVersion: 1, accepted: false, reason: "identity-mismatch" });
946
- return;
947
- }
948
- sendJson(response, 202, { schemaVersion: 1, accepted: true });
949
- setImmediate(() => { void Promise.resolve(control.onShutdown()).catch(() => {}); });
950
- return;
951
- }
952
-
953
- if (url.pathname === DASHBOARD_CONTROL_SESSION_PATH && method === "POST") {
954
- if (!control || typeof control.onSession !== "function") {
955
- sendJson(response, 404, { message: "Not found." });
956
- return;
957
- }
958
- if (!validDashboardControlRequest(request, {
959
- authority: allowedAuthority, origin: allowedOrigin, action: "session",
960
- })) {
961
- sendJson(response, 403, { message: "Dashboard control request was refused." });
962
- return;
963
- }
964
- const body = await readBoundedJsonBody(request);
965
- const pid = body?.pid;
966
- if (!body || Object.keys(body).length !== 2 || !Number.isSafeInteger(pid) || pid <= 1
967
- || !("expectedIdentity" in body)) {
968
- sendJson(response, 400, { message: "Dashboard session request was malformed." });
969
- return;
970
- }
971
- if (!exactDashboardIdentity(identity, body.expectedIdentity)) {
972
- sendJson(response, 409, { schemaVersion: 1, accepted: false, reason: "identity-mismatch" });
973
- return;
974
- }
975
- try {
976
- await control.onSession(pid);
977
- sendJson(response, 200, {
978
- schemaVersion: 1,
979
- accepted: true,
980
- });
981
- } catch (error) {
982
- sendJson(response, 409, {
983
- message: "Dashboard session registration was refused.",
984
- reason: error?.code === "TASKCHEF_DASHBOARD_SESSION_RETIRING"
985
- ? "retiring"
986
- : "refused",
987
- });
988
- }
989
- return;
990
- }
991
-
992
- if (url.pathname === "/api/settings" && method === "GET") {
993
- const profiles = [{ id: "personal", project: "Personal", ...await resolveRoles({ includeCatalog: true }) }];
994
- const usageProvider = {
995
- provider: "TaskChef",
996
- version: TASKCHEF_VERSION,
997
- };
998
- sendJson(response, 200, { profiles, usageProvider });
999
- return;
1000
- }
1001
-
1002
- const settingsMatch = url.pathname.match(/^\/api\/settings\/(orchestrator|planner|implementer|reviewer)$/);
1003
- if (settingsMatch && method === "POST") {
1004
- if (request.headers.origin !== allowedOrigin) {
1005
- sendJson(response, 403, { code: "invalid_origin", message: "Dashboard origin validation failed." });
1006
- return;
1007
- }
1008
- try {
1009
- const body = await readBoundedJsonBody(request);
1010
- const fields = Object.keys(body);
1011
- if (fields.length !== 3 || body.schemaVersion !== 1
1012
- || typeof body.model !== "string" || typeof body.effort !== "string") {
1013
- const error = new Error("Model role update has an invalid shape.");
1014
- error.code = "invalid_request";
1015
- throw error;
1016
- }
1017
- const profile = await updateRole(settingsMatch[1], body.model, body.effort);
1018
- sendJson(response, 200, { profile: { id: "personal", project: "Personal", ...profile } });
1019
- } catch (error) {
1020
- const status = error?.code === "invalid_request" ? 400
1021
- : error?.code === "unsupported_media_type" ? 415
1022
- : error?.code === "body_too_large" ? 413 : 409;
1023
- sendJson(response, status, {
1024
- code: error?.code ?? "role_update_failed",
1025
- message: error?.code === "invalid_request"
1026
- ? "Model role update is invalid."
1027
- : "Model role could not be saved. Review the profile and try again.",
1028
- });
1029
- }
1030
- return;
1031
- }
1032
-
1033
- if (url.pathname === "/api/snapshot" && (method === "GET" || method === "HEAD")) {
1034
- if (method === "HEAD") {
1035
- response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
1036
- response.end();
1037
- } else {
1038
- sendJson(response, 200, dashboardSnapshot());
1039
- }
1040
- return;
1041
- }
1042
-
1043
- if (url.pathname === "/api/events" && method === "GET") {
1044
- if (clients.size >= maxEventClients) {
1045
- sendJson(response, 503, { message: "Dashboard event-stream limit reached." });
1046
- return;
1047
- }
1048
- response.writeHead(200, {
1049
- ...securityHeaders("text/event-stream; charset=utf-8"),
1050
- Connection: "keep-alive",
1051
- });
1052
- let client;
1053
- client = createSseClient(response, {
1054
- onClose: () => {
1055
- clearInterval(client.heartbeat);
1056
- clients.delete(client);
1057
- },
1058
- });
1059
- clients.add(client);
1060
- client.write(`retry: 2000\n\n${ssePayload("snapshot", dashboardSnapshot())}`);
1061
- client.heartbeat = setInterval(() => {
1062
- if (!client.blocked) client.write(": heartbeat\n\n");
1063
- }, 15_000);
1064
- const { heartbeat } = client;
1065
- heartbeat.unref?.();
1066
- request.on("close", () => client.close());
1067
- response.on("error", () => client.close());
1068
- return;
1069
- }
1070
-
1071
- const detailMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)$/);
1072
- if (detailMatch && (method === "GET" || method === "HEAD")) {
1073
- const task = monitor.tasks.find((candidate) => candidate.id === detailMatch[1]);
1074
- if (!task) {
1075
- sendJson(response, 404, { message: "Task not found." });
1076
- return;
1077
- }
1078
- if (method === "HEAD") {
1079
- response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
1080
- response.end();
1081
- } else {
1082
- const usage = await taskUsageTracker.get(task).catch(() => ({
1083
- status: "unavailable",
1084
- reason: "Task usage is temporarily unavailable.",
1085
- task: null,
1086
- turns: {},
1087
- }));
1088
- sendJson(response, 200, {
1089
- schemaVersion: 1,
1090
- task: { ...taskDetailProjection(task), usage },
1091
- });
1092
- }
1093
- return;
1094
- }
1095
-
1096
- const transitionMatch = url.pathname.match(
1097
- /^\/api\/tasks\/([a-zA-Z0-9._-]+)\/manual-transition$/,
1098
- );
1099
- if (transitionMatch && method === "POST") {
1100
- if (request.headers.origin !== allowedOrigin) {
1101
- sendJson(response, 403, {
1102
- code: "invalid_origin",
1103
- message: "Dashboard origin validation failed.",
1104
- });
1105
- return;
1106
- }
1107
- try {
1108
- const body = await readBoundedJsonBody(request);
1109
- const result = await manuallyTransitionTask(
1110
- monitor.workspace,
1111
- transitionMatch[1],
1112
- manualTransitionInput(body),
1113
- );
1114
- await monitor.refresh({ force: true }).catch(() => {});
1115
- sendJson(response, 200, {
1116
- schemaVersion: 1,
1117
- task: taskDetailProjection(result.task),
1118
- idempotent: result.idempotent,
1119
- });
1120
- } catch (error) {
1121
- const failure = manualTransitionErrorResponse(error);
1122
- sendJson(response, failure.status, failure.body);
1123
- }
1124
- return;
1125
- }
1126
-
1127
- const taskMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)\/open-codex$/);
1128
- if (taskMatch && method === "POST") {
1129
- if (request.headers.origin !== allowedOrigin) {
1130
- sendJson(response, 403, { message: "Dashboard origin validation failed." });
1131
- return;
1132
- }
1133
- const task = monitor.tasks.find((candidate) => candidate.id === taskMatch[1]);
1134
- if (!task) {
1135
- sendJson(response, 404, { message: "Task not found." });
1136
- return;
1137
- }
1138
- try {
1139
- if (isCodexThreadDeepLinkId(task.threadId)) {
1140
- if (openThread) await openThread(task.threadId);
1141
- else await openThreadInCodex(task.threadId);
1142
- sendJson(response, 202, {});
1143
- return;
1144
- }
1145
- const trustedProject = (await readConfig(monitor.workspace, { checkPaths: false })).projects
1146
- .find((project) => project.path === task.project.path);
1147
- if (!trustedProject) {
1148
- sendJson(response, 409, {
1149
- message: "This historical task no longer matches a configured project.",
1150
- });
1151
- return;
1152
- }
1153
- const canonicalProjectPath = trustedProject.isGitRepository
1154
- ? await canonicalGitRoot(trustedProject.path)
1155
- : await canonicalDirectory(trustedProject.path);
1156
- if (canonicalProjectPath !== trustedProject.path) {
1157
- sendJson(response, 409, {
1158
- message: "This configured project has moved. Repair the TaskChef project first.",
1159
- });
1160
- return;
1161
- }
1162
- if (openProject) await openProject(canonicalProjectPath);
1163
- else await openWorkspaceInCodex(canonicalProjectPath);
1164
- sendJson(response, 202, {
1165
- message: task.threadId
1166
- ? "Opened the project in Codex; this thread ID cannot use direct navigation."
1167
- : "Opened the project in Codex; this task does not yet have a thread ID.",
1168
- });
1169
- } catch {
1170
- sendJson(response, 503, {
1171
- message: "Codex could not be opened. Open the project and select the recorded thread instead.",
1172
- });
1173
- }
1174
- return;
1175
- }
1176
-
1177
- const archiveMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)\/archive-codex$/);
1178
- if (archiveMatch && method === "POST") {
1179
- if (!archiveEnabled) {
1180
- sendJson(response, 404, { message: "Chat archiving is not available." });
1181
- return;
1182
- }
1183
- if (request.headers.origin !== allowedOrigin) {
1184
- sendJson(response, 403, { message: "Dashboard origin validation failed." });
1185
- return;
1186
- }
1187
- const taskId = archiveMatch[1];
1188
- const visibleTask = monitor.tasks.find((candidate) => candidate.id === taskId);
1189
- if (!visibleTask) {
1190
- sendJson(response, 404, { message: "Task not found." });
1191
- return;
1192
- }
1193
- if (!isCodexThreadDeepLinkId(visibleTask.threadId)) {
1194
- sendJson(response, 409, { message: "This task does not have an archivable Codex chat." });
1195
- return;
1196
- }
1197
- if (visibleTask.status === "working") {
1198
- sendJson(response, 409, { message: "Working tasks cannot be archived from the dashboard." });
1199
- return;
1200
- }
1201
- if (archiveRequests.has(taskId)) {
1202
- sendJson(response, 409, { message: "This Codex chat is already being archived." });
1203
- return;
1204
- }
1205
- archiveRequests.add(taskId);
1206
- let releaseWorkspaceLock = null;
1207
- try {
1208
- const cli = await discoverArchiveCli();
1209
- releaseWorkspaceLock = await acquireWorkspaceLock(monitor.workspace);
1210
- let task;
1211
- try {
1212
- task = await readTask(monitor.workspace, taskId);
1213
- } catch (error) {
1214
- if (/^task not found:/.test(error?.message ?? "")) {
1215
- sendJson(response, 404, { message: "Task not found." });
1216
- return;
1217
- }
1218
- throw error;
1219
- }
1220
- if (!isCodexThreadDeepLinkId(task.threadId)) {
1221
- sendJson(response, 409, { message: "This task does not have an archivable Codex chat." });
1222
- return;
1223
- }
1224
- if (task.status === "working") {
1225
- sendJson(response, 409, { message: "Working tasks cannot be archived from the dashboard." });
1226
- return;
1227
- }
1228
- await archiveThread(task.threadId, { cli, timeoutMs: 4_000 });
1229
- sendJson(response, 200, {
1230
- message: "Archived the Codex chat. TaskChef history remains available.",
1231
- status: "archived",
1232
- });
1233
- } catch (error) {
1234
- if (error?.killed || error?.code === "ETIMEDOUT") {
1235
- sendJson(response, 504, { message: "Codex chat archiving timed out. Try again." });
1236
- } else if (/requires the Codex CLI bundled/i.test(error?.message ?? "")) {
1237
- sendJson(response, 503, {
1238
- message: "Chat archiving requires the Codex CLI bundled with the ChatGPT or Codex desktop app.",
1239
- });
1240
- } else {
1241
- sendJson(response, 409, {
1242
- message: "Codex could not archive this chat. It may be active, already archived, or stored on another host or profile.",
1243
- });
1244
- }
1245
- } finally {
1246
- await releaseWorkspaceLock?.();
1247
- archiveRequests.delete(taskId);
1248
- }
1249
- return;
1250
- }
1251
-
1252
- if (method === "POST") {
1253
- sendJson(response, 404, { message: "Not found." });
1254
- return;
1255
- }
1256
-
1257
- const staticFile = STATIC_FILES.get(url.pathname);
1258
- if (!staticFile) {
1259
- sendJson(response, 404, { message: "Not found." });
1260
- return;
1261
- }
1262
- const [filePath, contentType] = staticFile;
1263
- let body = await readFile(filePath);
1264
- let styleNonce = null;
1265
- if (url.pathname === "/") {
1266
- styleNonce = randomBytes(18).toString("base64");
1267
- body = Buffer.from(body.toString("utf8").replaceAll("__TASKCHEF_STYLE_NONCE__", styleNonce));
1268
- }
1269
- response.writeHead(200, securityHeaders(contentType, { styleNonce }));
1270
- response.end(method === "HEAD" ? undefined : body);
1271
- };
1272
-
1273
- requestHandler = (request, response) => {
1274
- handleRequest(request, response).catch(() => {
1275
- if (response.headersSent) response.destroy();
1276
- else sendJson(response, 500, { message: "Dashboard request failed." });
1277
- });
1278
- };
1279
- return {
1280
- host,
1281
- port: boundPort,
1282
- origin: allowedOrigin,
1283
- url: `${allowedOrigin}/`,
1284
- identity,
1285
- monitor,
1286
- usageSummaryMonitor: taskUsageSummaryMonitor,
1287
- get eventClientCount() { return clients.size; },
1288
- async close() {
1289
- monitor.off("snapshot", snapshotListener);
1290
- monitor.off("monitorError", errorListener);
1291
- taskUsageSummaryMonitor.off("change", usageSummaryListener);
1292
- monitor.close();
1293
- taskUsageSummaryMonitor.close();
1294
- taskUsageTracker.close?.();
1295
- clearInterval(usagePreloadTimer);
1296
- for (const client of clients) client.close();
1297
- const closed = new Promise((resolve, reject) => server.close((error) =>
1298
- error ? reject(error) : resolve()));
1299
- server.closeAllConnections?.();
1300
- await closed;
1301
- },
1302
- };
1303
- }