taskchef 5.8.0 → 5.9.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": "5.8.0",
3
+ "version": "5.9.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
@@ -162,6 +162,53 @@ when its metadata is newer than the callback; missing callbacks and other
162
162
  anomalies also receive at most one targeted read. Reporting writes nothing and
163
163
  never polls.
164
164
 
165
+ ### Watch the local dashboard
166
+
167
+ Run a local dashboard when you want the task history to remain visible while
168
+ executors report results:
169
+
170
+ ```sh
171
+ taskchef dashboard
172
+ ```
173
+
174
+ Open the printed capability URL, which starts with `http://127.0.0.1:3210` and
175
+ contains a one-time launch token. Do not share it: the browser exchanges it for
176
+ a local session cookie before loading task data, then removes the token from the
177
+ address bar. Use `--port <number>` to choose another port and `--workspace
178
+ <path>` to override the normal workspace resolution. Press Ctrl+C in the
179
+ terminal to stop the server.
180
+
181
+ The dashboard:
182
+
183
+ - orders tasks by their latest semantic or identity update;
184
+ - filters by project and status;
185
+ - shows dismissible notifications when tasks are added or changed;
186
+ - reveals the original instruction, latest semantic result, and task metadata;
187
+ - opens a task's project in Codex on request, while preserving the recorded
188
+ thread ID for manual task selection.
189
+
190
+ The server binds only to the numeric IPv4 loopback interface, requires the
191
+ unguessable launch capability before serving instructions or results, validates
192
+ the same task schema as the CLI, retains its last valid snapshot if the log
193
+ becomes invalid, and never writes dispatcher-workspace files. It limits event
194
+ streams and disconnects slow clients instead of buffering snapshots without
195
+ bound. It watches the workspace directory
196
+ so TaskChef's atomic log replacement remains visible, uses a low-frequency file
197
+ metadata check to recover from missed watcher events, and streams snapshots to
198
+ the browser with automatic reconnect. To keep untrusted history from exhausting
199
+ the server or browser, the dashboard rejects logs above 16 MiB, histories above
200
+ 2,000 tasks, and unusually large display fields while retaining the last valid
201
+ snapshot. The data CLI remains unaffected by these display limits.
202
+
203
+ Direct desktop navigation to a recorded thread is not exposed by the supported
204
+ `codex app` CLI, which accepts only a workspace path. A task-level refresh would
205
+ require native Codex metadata tools that are available to the report skill, not
206
+ to a standalone browser page. TaskChef also does not submit replies from the
207
+ dashboard: `codex resume <session> [prompt]` starts an interactive CLI session
208
+ and may execute the prompt immediately, rather than opening a reviewed draft in
209
+ the desktop app. These integrations remain deferred until Codex provides a
210
+ stable task deep link or explicit draft handoff.
211
+
165
212
  ### Manage configured projects
166
213
 
167
214
  Use `$taskchef-bootstrap` to scan local Codex projects and refresh the managed
package/index.js CHANGED
@@ -59,6 +59,13 @@ export {
59
59
  openWorkspaceInCodex,
60
60
  } from "./src/codex-app.js";
61
61
 
62
+ export {
63
+ DashboardMonitor,
64
+ createDashboardServer,
65
+ dashboardAuthority,
66
+ sortTasksByMeaningfulUpdate,
67
+ } from "./src/dashboard.js";
68
+
62
69
  export { createTaskChefMcpServer } from "./src/mcp.js";
63
70
 
64
71
  export { handleInitialPromptHook } from "./src/hook.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "5.8.0",
3
+ "version": "5.9.0",
4
4
  "description": "A non-blocking interactive dispatcher for visible Codex tasks.",
5
5
  "license": "MIT",
6
6
  "author": "Favo Yang",
@@ -45,7 +45,7 @@
45
45
  "scripts": {
46
46
  "benchmark:dispatch": "node scripts/benchmark-dispatch-prepare.js",
47
47
  "benchmark:e2e": "node scripts/e2e-benchmark.js",
48
- "test": "node --test tests/taskchef.test.js"
48
+ "test": "node --test tests/*.test.js"
49
49
  },
50
50
  "dependencies": {
51
51
  "@modelcontextprotocol/sdk": "^1.30.0",
package/src/cli.js CHANGED
@@ -2,6 +2,7 @@ import { access, readFile, realpath } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
4
  import { openWorkspaceInCodex } from "./codex-app.js";
5
+ import { createDashboardServer } from "./dashboard.js";
5
6
  import { resolveWorkspacePath } from "./workspace-path.js";
6
7
 
7
8
  import {
@@ -409,11 +410,51 @@ async function taskSummary(args) {
409
410
  return 0;
410
411
  }
411
412
 
413
+ function dashboardPort(args) {
414
+ const value = option(args, "--port", "3210");
415
+ if (!/^\d+$/.test(value)) throw new Error("--port must be an integer from 0 to 65535");
416
+ const port = Number(value);
417
+ if (port > 65_535) throw new Error("--port must be an integer from 0 to 65535");
418
+ return port;
419
+ }
420
+
421
+ async function dashboard(args) {
422
+ validateCommandArgs(args, 1, {
423
+ values: ["--port", "--workspace"],
424
+ switches: ["--json"],
425
+ });
426
+ const server = await createDashboardServer({
427
+ workspace: workspaceRoot(args),
428
+ port: dashboardPort(args),
429
+ });
430
+ print({
431
+ schemaVersion: 1,
432
+ url: server.url,
433
+ workspace: server.monitor.workspace,
434
+ }, args, (value) => [
435
+ `TaskChef dashboard: ${value.url}`,
436
+ `Workspace: ${value.workspace}`,
437
+ "Press Ctrl+C to stop.",
438
+ ].join("\n"));
439
+
440
+ let stop;
441
+ await new Promise((resolve) => {
442
+ stop = resolve;
443
+ process.once("SIGINT", stop);
444
+ process.once("SIGTERM", stop);
445
+ });
446
+ process.off("SIGINT", stop);
447
+ process.off("SIGTERM", stop);
448
+ await server.close();
449
+ return 0;
450
+ }
451
+
412
452
  function usage() {
413
453
  process.stdout.write(`TaskChef workspace utility
414
454
 
415
455
  Usage:
416
456
  taskchef help
457
+ taskchef dashboard [--port <number>] [--json] [--workspace <path>]
417
458
  taskchef doctor [--json] [--workspace <path>]
418
459
  taskchef workspace path [--json] [--workspace <path>]
419
460
  taskchef workspace init [--register-codex] [--codex-cli <path>] [--json] [--workspace <path>]
@@ -435,6 +476,8 @@ Project import reads a JSON
435
476
  array from a file, or from standard input when the source is '-' or omitted.
436
477
  Workspace resolution precedence is --workspace, TASKCHEF_WORKSPACE, then
437
478
  ~/.agents/taskchef.
479
+ The dashboard binds to 127.0.0.1 and reads the canonical task log without
480
+ modifying dispatcher-workspace files.
438
481
  `);
439
482
  }
440
483
 
@@ -443,6 +486,7 @@ export async function runCli(args) {
443
486
  usage();
444
487
  return 0;
445
488
  }
489
+ if (args[0] === "dashboard") return dashboard(args);
446
490
  if (args[0] === "doctor") return doctor(args);
447
491
  if (args[0] === "workspace" && args[1] === "path") return workspacePath(args);
448
492
  if (args[0] === "workspace" && args[1] === "init") return initialize(args);
@@ -0,0 +1,255 @@
1
+ import {
2
+ findCurrentTask,
3
+ reconcileNotifications,
4
+ } from "./state.js";
5
+
6
+ const state = {
7
+ tasks: [],
8
+ signatures: new Map(),
9
+ notifications: [],
10
+ initialized: false,
11
+ selectedTask: null,
12
+ };
13
+
14
+ const elements = {
15
+ clearNotifications: document.querySelector("#clear-notifications"),
16
+ closeDialog: document.querySelector("#close-dialog"),
17
+ connectionDot: document.querySelector("#connection-dot"),
18
+ connectionLabel: document.querySelector("#connection-label"),
19
+ copyThreadId: document.querySelector("#copy-thread-id"),
20
+ dashboardMessage: document.querySelector("#dashboard-message"),
21
+ dialog: document.querySelector("#task-dialog"),
22
+ dialogInstruction: document.querySelector("#dialog-instruction"),
23
+ dialogMetadata: document.querySelector("#dialog-metadata"),
24
+ dialogProject: document.querySelector("#dialog-project"),
25
+ dialogSummary: document.querySelector("#dialog-summary"),
26
+ dialogTitle: document.querySelector("#dialog-title"),
27
+ emptyState: document.querySelector("#empty-state"),
28
+ notifications: document.querySelector("#notifications"),
29
+ openProject: document.querySelector("#open-project"),
30
+ projectFilter: document.querySelector("#project-filter"),
31
+ statusFilter: document.querySelector("#status-filter"),
32
+ taskCount: document.querySelector("#task-count"),
33
+ taskList: document.querySelector("#task-list"),
34
+ toastList: document.querySelector("#toast-list"),
35
+ };
36
+
37
+ function statusLabel(task) {
38
+ return task.status === null ? "unresolved" : task.status.replaceAll("_", " ");
39
+ }
40
+
41
+ function formatTime(value) {
42
+ if (!value) return "—";
43
+ return new Intl.DateTimeFormat(undefined, {
44
+ dateStyle: "medium",
45
+ timeStyle: "short",
46
+ }).format(new Date(value));
47
+ }
48
+
49
+ function setConnection(connected) {
50
+ elements.connectionDot.classList.toggle("connected", connected);
51
+ elements.connectionLabel.textContent = connected ? "Live" : "Reconnecting…";
52
+ }
53
+
54
+ function replaceOptions(select, values, allLabel) {
55
+ const selected = select.value;
56
+ select.replaceChildren();
57
+ const all = document.createElement("option");
58
+ all.value = "";
59
+ all.textContent = allLabel;
60
+ select.append(all);
61
+ for (const value of values) {
62
+ const option = document.createElement("option");
63
+ option.value = value;
64
+ option.textContent = value;
65
+ select.append(option);
66
+ }
67
+ if (values.includes(selected)) select.value = selected;
68
+ }
69
+
70
+ function notificationToast(notification) {
71
+ const task = findCurrentTask(state.tasks, notification.taskId);
72
+ if (!task) return null;
73
+ const toast = document.createElement("div");
74
+ toast.className = "toast";
75
+ const text = document.createElement("button");
76
+ text.type = "button";
77
+ text.className = "toast-content";
78
+ const title = document.createElement("strong");
79
+ title.textContent = notification.kind === "new" ? "New task" : "Task updated";
80
+ const description = document.createElement("span");
81
+ description.textContent = `${task.title} · ${statusLabel(task)}`;
82
+ text.append(title, description);
83
+ text.addEventListener("click", () => {
84
+ const current = findCurrentTask(state.tasks, notification.taskId);
85
+ if (current) openDialog(current);
86
+ else showMessage("This task is no longer present in the current snapshot.");
87
+ });
88
+ const dismiss = document.createElement("button");
89
+ dismiss.type = "button";
90
+ dismiss.className = "icon-button";
91
+ dismiss.setAttribute("aria-label", `Dismiss notification for ${task.title}`);
92
+ dismiss.textContent = "×";
93
+ dismiss.addEventListener("click", () => {
94
+ state.notifications = state.notifications.filter(({ id }) => id !== notification.id);
95
+ renderNotifications();
96
+ });
97
+ toast.append(text, dismiss);
98
+ return toast;
99
+ }
100
+
101
+ function renderNotifications() {
102
+ elements.toastList.replaceChildren(
103
+ ...state.notifications.map(notificationToast).filter(Boolean),
104
+ );
105
+ elements.notifications.hidden = state.notifications.length === 0;
106
+ }
107
+
108
+ function detailRow(term, value) {
109
+ const dt = document.createElement("dt");
110
+ dt.textContent = term;
111
+ const dd = document.createElement("dd");
112
+ dd.textContent = value ?? "—";
113
+ return [dt, dd];
114
+ }
115
+
116
+ function openDialog(task) {
117
+ state.selectedTask = task;
118
+ elements.dialogProject.textContent = task.project.name;
119
+ elements.dialogTitle.textContent = task.title;
120
+ elements.dialogSummary.textContent = task.summary ?? "No semantic result has been reported yet.";
121
+ elements.dialogInstruction.textContent = task.instruction;
122
+ elements.copyThreadId.disabled = !task.threadId;
123
+ elements.dialogMetadata.replaceChildren(
124
+ ...detailRow("Status", statusLabel(task)),
125
+ ...detailRow("Task ID", task.id),
126
+ ...detailRow("Thread ID", task.threadId),
127
+ ...detailRow("Turn ID", task.turnId),
128
+ ...detailRow("Project path", task.project.path),
129
+ ...detailRow("Created", formatTime(task.createdAt)),
130
+ ...detailRow("Updated", formatTime(task.updatedAt ?? task.createdAt)),
131
+ ...detailRow("Updated by", task.updatedBy),
132
+ );
133
+ if (!elements.dialog.open) elements.dialog.showModal();
134
+ }
135
+
136
+ function taskCard(task) {
137
+ const article = document.createElement("article");
138
+ article.className = "task-card";
139
+ const heading = document.createElement("div");
140
+ heading.className = "task-heading";
141
+ const title = document.createElement("button");
142
+ title.type = "button";
143
+ title.className = "task-title";
144
+ title.textContent = task.title;
145
+ title.addEventListener("click", () => openDialog(task));
146
+ const badge = document.createElement("span");
147
+ badge.className = `status status-${task.status ?? "unresolved"}`;
148
+ badge.textContent = statusLabel(task);
149
+ heading.append(title, badge);
150
+ const project = document.createElement("p");
151
+ project.className = "task-project";
152
+ project.textContent = task.project.name;
153
+ const summary = document.createElement("p");
154
+ summary.className = "task-summary";
155
+ summary.textContent = task.summary ?? "No semantic result reported yet.";
156
+ const time = document.createElement("time");
157
+ time.dateTime = task.updatedAt ?? task.createdAt;
158
+ time.textContent = `Updated ${formatTime(task.updatedAt ?? task.createdAt)}`;
159
+ article.append(heading, project, summary, time);
160
+ return article;
161
+ }
162
+
163
+ function render() {
164
+ const project = elements.projectFilter.value;
165
+ const status = elements.statusFilter.value;
166
+ const visible = state.tasks.filter((task) =>
167
+ (!project || task.project.name === project)
168
+ && (!status || statusLabel(task) === status));
169
+ elements.taskList.replaceChildren(...visible.map(taskCard));
170
+ elements.emptyState.hidden = visible.length > 0;
171
+ elements.taskCount.textContent = `${visible.length} of ${state.tasks.length} task${state.tasks.length === 1 ? "" : "s"}`;
172
+ }
173
+
174
+ function applySnapshot(snapshot) {
175
+ const reconciled = reconcileNotifications({
176
+ initialized: state.initialized,
177
+ notifications: state.notifications,
178
+ signatures: state.signatures,
179
+ }, snapshot.tasks, snapshot.revision);
180
+ state.tasks = snapshot.tasks;
181
+ state.signatures = reconciled.signatures;
182
+ state.notifications = reconciled.notifications;
183
+ state.initialized = true;
184
+ if (snapshot.healthy === false) {
185
+ showMessage("The task log is temporarily unavailable. Showing the last valid snapshot.");
186
+ } else {
187
+ elements.dashboardMessage.hidden = true;
188
+ }
189
+ replaceOptions(
190
+ elements.projectFilter,
191
+ [...new Set(state.tasks.map((task) => task.project.name))].sort(),
192
+ "All projects",
193
+ );
194
+ replaceOptions(
195
+ elements.statusFilter,
196
+ [...new Set(state.tasks.map(statusLabel))].sort(),
197
+ "All statuses",
198
+ );
199
+ if (state.selectedTask) {
200
+ const updated = state.tasks.find((task) => task.id === state.selectedTask.id);
201
+ if (updated && elements.dialog.open) openDialog(updated);
202
+ }
203
+ renderNotifications();
204
+ render();
205
+ }
206
+
207
+ function showMessage(message) {
208
+ elements.dashboardMessage.textContent = message;
209
+ elements.dashboardMessage.hidden = false;
210
+ }
211
+
212
+ const events = new EventSource("/api/events");
213
+ events.addEventListener("open", () => setConnection(true));
214
+ events.addEventListener("error", () => setConnection(false));
215
+ events.addEventListener("snapshot", (event) => {
216
+ setConnection(true);
217
+ applySnapshot(JSON.parse(event.data));
218
+ });
219
+ events.addEventListener("dashboard-error", (event) => {
220
+ showMessage(JSON.parse(event.data).message);
221
+ });
222
+
223
+ elements.projectFilter.addEventListener("change", render);
224
+ elements.statusFilter.addEventListener("change", render);
225
+ elements.clearNotifications.addEventListener("click", () => {
226
+ state.notifications = [];
227
+ renderNotifications();
228
+ });
229
+ elements.closeDialog.addEventListener("click", () => elements.dialog.close());
230
+ elements.dialog.addEventListener("click", (event) => {
231
+ if (event.target === elements.dialog) elements.dialog.close();
232
+ });
233
+ elements.copyThreadId.addEventListener("click", async () => {
234
+ if (!state.selectedTask?.threadId) return;
235
+ try {
236
+ await navigator.clipboard.writeText(state.selectedTask.threadId);
237
+ elements.copyThreadId.textContent = "Copied";
238
+ setTimeout(() => { elements.copyThreadId.textContent = "Copy thread ID"; }, 1_500);
239
+ } catch {
240
+ showMessage("Clipboard access is unavailable. Copy the thread ID from the metadata below.");
241
+ }
242
+ });
243
+ elements.openProject.addEventListener("click", async () => {
244
+ if (!state.selectedTask) return;
245
+ elements.openProject.disabled = true;
246
+ try {
247
+ const response = await fetch(`/api/tasks/${encodeURIComponent(state.selectedTask.id)}/open-project`, {
248
+ method: "POST",
249
+ });
250
+ const result = await response.json();
251
+ showMessage(result.message);
252
+ } finally {
253
+ elements.openProject.disabled = false;
254
+ }
255
+ });
@@ -0,0 +1,86 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="light dark">
7
+ <title>TaskChef dashboard</title>
8
+ <link rel="stylesheet" href="/styles.css">
9
+ <script src="/app.js" type="module"></script>
10
+ </head>
11
+ <body>
12
+ <header class="site-header">
13
+ <div>
14
+ <p class="eyebrow">TaskChef</p>
15
+ <h1>Task dashboard</h1>
16
+ <p class="subtitle">Latest delegated work, updated as TaskChef reports change.</p>
17
+ </div>
18
+ <div class="connection" role="status" aria-live="polite">
19
+ <span id="connection-dot" class="connection-dot"></span>
20
+ <span id="connection-label">Connecting…</span>
21
+ </div>
22
+ </header>
23
+
24
+ <main>
25
+ <section class="toolbar" aria-label="Task filters">
26
+ <label>
27
+ Project
28
+ <select id="project-filter">
29
+ <option value="">All projects</option>
30
+ </select>
31
+ </label>
32
+ <label>
33
+ Status
34
+ <select id="status-filter">
35
+ <option value="">All statuses</option>
36
+ </select>
37
+ </label>
38
+ <p id="task-count" class="task-count" aria-live="polite"></p>
39
+ </section>
40
+
41
+ <div id="dashboard-message" class="dashboard-message" role="status" hidden></div>
42
+ <section aria-labelledby="tasks-heading">
43
+ <h2 id="tasks-heading" class="visually-hidden">Tasks</h2>
44
+ <div id="task-list" class="task-list"></div>
45
+ <div id="empty-state" class="empty-state" hidden>
46
+ <h2>No tasks match these filters</h2>
47
+ <p>Choose a different project or status to see more work.</p>
48
+ </div>
49
+ </section>
50
+ </main>
51
+
52
+ <aside id="notifications" class="notifications" aria-label="Task notifications" hidden>
53
+ <div class="notifications-heading">
54
+ <strong>Updates</strong>
55
+ <button id="clear-notifications" class="text-button" type="button">Clear all</button>
56
+ </div>
57
+ <div id="toast-list" class="toast-list" aria-live="polite"></div>
58
+ </aside>
59
+
60
+ <dialog id="task-dialog" aria-labelledby="dialog-title">
61
+ <div class="dialog-header">
62
+ <div>
63
+ <p id="dialog-project" class="eyebrow"></p>
64
+ <h2 id="dialog-title"></h2>
65
+ </div>
66
+ <button id="close-dialog" class="icon-button" type="button" aria-label="Close task details">×</button>
67
+ </div>
68
+ <div class="dialog-actions">
69
+ <button id="open-project" class="primary-button" type="button">Open project in Codex</button>
70
+ <button id="copy-thread-id" class="secondary-button" type="button">Copy thread ID</button>
71
+ </div>
72
+ <section>
73
+ <h3>Result</h3>
74
+ <p id="dialog-summary" class="preserve-lines"></p>
75
+ </section>
76
+ <section>
77
+ <h3>Original instruction</h3>
78
+ <pre id="dialog-instruction"></pre>
79
+ </section>
80
+ <section>
81
+ <h3>Metadata</h3>
82
+ <dl id="dialog-metadata" class="metadata"></dl>
83
+ </section>
84
+ </dialog>
85
+ </body>
86
+ </html>
@@ -0,0 +1,40 @@
1
+ export const MAX_NOTIFICATIONS = 50;
2
+
3
+ export function taskSignature(task) {
4
+ return JSON.stringify([
5
+ task.threadId,
6
+ task.status,
7
+ task.summary,
8
+ task.turnId,
9
+ task.updatedAt,
10
+ task.updatedBy,
11
+ ]);
12
+ }
13
+
14
+ export function findCurrentTask(tasks, taskId) {
15
+ return tasks.find((task) => task.id === taskId) ?? null;
16
+ }
17
+
18
+ export function reconcileNotifications(
19
+ { initialized, notifications, signatures },
20
+ tasks,
21
+ revision,
22
+ ) {
23
+ const nextSignatures = new Map(tasks.map((task) => [task.id, taskSignature(task)]));
24
+ if (!initialized) return { notifications, signatures: nextSignatures };
25
+ const additions = [];
26
+ for (const task of tasks) {
27
+ let kind = null;
28
+ if (!signatures.has(task.id)) kind = "new";
29
+ else if (signatures.get(task.id) !== nextSignatures.get(task.id)) kind = "changed";
30
+ if (kind) additions.push({
31
+ id: `${revision}:${task.id}`,
32
+ kind,
33
+ taskId: task.id,
34
+ });
35
+ }
36
+ return {
37
+ notifications: [...additions, ...notifications].slice(0, MAX_NOTIFICATIONS),
38
+ signatures: nextSignatures,
39
+ };
40
+ }
@@ -0,0 +1,147 @@
1
+ :root {
2
+ color-scheme: light;
3
+ --background: #f5f4f0;
4
+ --surface: #ffffff;
5
+ --surface-muted: #eeece6;
6
+ --text: #1d211f;
7
+ --muted: #626964;
8
+ --border: #d8d7d1;
9
+ --accent: #215f4a;
10
+ --accent-soft: #dcebe4;
11
+ --danger: #8b3a35;
12
+ --warning: #8a5c17;
13
+ --shadow: 0 12px 30px rgb(24 33 29 / 10%);
14
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
15
+ }
16
+
17
+ * { box-sizing: border-box; }
18
+
19
+ body {
20
+ margin: 0;
21
+ background: var(--background);
22
+ color: var(--text);
23
+ line-height: 1.5;
24
+ }
25
+
26
+ button, select { font: inherit; }
27
+ button { color: inherit; }
28
+
29
+ .site-header, main {
30
+ width: min(1080px, calc(100% - 40px));
31
+ margin-inline: auto;
32
+ }
33
+
34
+ .site-header {
35
+ display: flex;
36
+ align-items: flex-end;
37
+ justify-content: space-between;
38
+ gap: 24px;
39
+ padding-block: 48px 28px;
40
+ border-bottom: 1px solid var(--border);
41
+ }
42
+
43
+ h1, h2, h3, p { margin-top: 0; }
44
+ h1 { margin-bottom: 6px; font-size: clamp(2rem, 5vw, 3.4rem); line-height: 1; letter-spacing: -0.04em; }
45
+ h2 { line-height: 1.2; }
46
+ h3 { margin-bottom: 8px; font-size: 0.84rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
47
+
48
+ .eyebrow { margin-bottom: 10px; color: var(--accent); font-size: 0.78rem; font-weight: 750; letter-spacing: 0.15em; text-transform: uppercase; }
49
+ .subtitle, .task-project, time { color: var(--muted); }
50
+ .subtitle { margin-bottom: 0; }
51
+
52
+ .connection { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 0.9rem; }
53
+ .connection-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--warning); }
54
+ .connection-dot.connected { background: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); }
55
+
56
+ main { padding-block: 28px 80px; }
57
+
58
+ .toolbar {
59
+ display: flex;
60
+ align-items: flex-end;
61
+ gap: 16px;
62
+ margin-bottom: 22px;
63
+ }
64
+
65
+ label { display: grid; gap: 6px; color: var(--muted); font-size: 0.78rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; }
66
+ select { min-width: 180px; padding: 9px 34px 9px 11px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); }
67
+ .task-count { margin: 0 0 9px auto; color: var(--muted); font-size: 0.9rem; }
68
+
69
+ .task-list { display: grid; gap: 12px; }
70
+ .task-card { padding: 20px 22px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: 0 1px 0 rgb(0 0 0 / 2%); }
71
+ .task-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
72
+ .task-title { padding: 0; border: 0; background: none; font-size: 1.08rem; font-weight: 720; text-align: left; cursor: pointer; }
73
+ .task-title:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
74
+ .task-project { margin: 3px 0 12px; font-size: 0.86rem; }
75
+ .task-summary { max-width: 78ch; margin-bottom: 14px; }
76
+ time { font-size: 0.78rem; }
77
+
78
+ .status { flex: none; padding: 4px 9px; border-radius: 999px; background: var(--surface-muted); color: var(--muted); font-size: 0.72rem; font-weight: 750; text-transform: capitalize; }
79
+ .status-completed { background: var(--accent-soft); color: var(--accent); }
80
+ .status-failed { background: #f3dfdd; color: var(--danger); }
81
+ .status-needs_input { background: #f4e9ce; color: var(--warning); }
82
+
83
+ .empty-state { padding: 60px 20px; border: 1px dashed var(--border); border-radius: 10px; text-align: center; color: var(--muted); }
84
+ .empty-state h2 { margin-bottom: 6px; color: var(--text); }
85
+
86
+ .dashboard-message { margin-bottom: 18px; padding: 11px 14px; border: 1px solid #d8c58e; border-radius: 7px; background: #fff7de; color: #604613; }
87
+
88
+ .notifications { position: fixed; right: 20px; bottom: 20px; z-index: 3; width: min(380px, calc(100vw - 40px)); padding: 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); }
89
+ .notifications-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
90
+ .toast-list { display: grid; gap: 7px; max-height: 50vh; overflow: auto; }
91
+ .toast { display: flex; align-items: flex-start; gap: 8px; padding: 10px; border-radius: 7px; background: var(--surface-muted); }
92
+ .toast-content { display: grid; flex: 1; gap: 2px; padding: 0; border: 0; background: none; text-align: left; cursor: pointer; }
93
+ .toast-content span { color: var(--muted); font-size: 0.85rem; }
94
+
95
+ .text-button, .icon-button { border: 0; background: none; cursor: pointer; }
96
+ .text-button { color: var(--accent); font-size: 0.82rem; font-weight: 700; }
97
+ .icon-button { padding: 0 4px; color: var(--muted); font-size: 1.4rem; line-height: 1; }
98
+
99
+ dialog { width: min(760px, calc(100vw - 32px)); max-height: min(82vh, 900px); padding: 28px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
100
+ dialog::backdrop { background: rgb(18 23 21 / 50%); backdrop-filter: blur(2px); }
101
+ .dialog-header { display: flex; justify-content: space-between; gap: 20px; }
102
+ .dialog-header h2 { margin-bottom: 12px; font-size: 1.6rem; }
103
+ .dialog-actions { display: flex; gap: 8px; margin-bottom: 26px; }
104
+ .primary-button, .secondary-button { padding: 8px 12px; border-radius: 7px; font-weight: 700; cursor: pointer; }
105
+ .primary-button { border: 1px solid var(--accent); background: var(--accent); color: white; }
106
+ .secondary-button { border: 1px solid var(--border); background: var(--surface); }
107
+ .primary-button:disabled, .secondary-button:disabled { opacity: 0.5; cursor: not-allowed; }
108
+ dialog section + section { margin-top: 24px; }
109
+ .preserve-lines { white-space: pre-wrap; }
110
+ 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; }
111
+ .metadata { display: grid; grid-template-columns: minmax(100px, 150px) 1fr; margin: 0; font-size: 0.88rem; }
112
+ .metadata dt, .metadata dd { padding: 7px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
113
+ .metadata dt { color: var(--muted); }
114
+ .metadata dd { margin: 0; }
115
+
116
+ .visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
117
+ :focus-visible { outline: 3px solid rgb(33 95 74 / 35%); outline-offset: 3px; }
118
+
119
+ @media (max-width: 650px) {
120
+ .site-header { align-items: flex-start; padding-top: 32px; }
121
+ .toolbar { align-items: stretch; flex-direction: column; }
122
+ .toolbar label, .toolbar select { width: 100%; }
123
+ .task-count { margin: 0; }
124
+ .task-heading { align-items: flex-start; }
125
+ .dialog-actions { align-items: stretch; flex-direction: column; }
126
+ .metadata { grid-template-columns: 1fr; }
127
+ .metadata dt { padding-bottom: 0; border-bottom: 0; }
128
+ }
129
+
130
+ @media (prefers-color-scheme: dark) {
131
+ :root {
132
+ color-scheme: dark;
133
+ --background: #181b19;
134
+ --surface: #222624;
135
+ --surface-muted: #2d322f;
136
+ --text: #edf1ee;
137
+ --muted: #aeb7b1;
138
+ --border: #3d4440;
139
+ --accent: #7dc1a4;
140
+ --accent-soft: #244436;
141
+ --danger: #ef9991;
142
+ --warning: #e4bd77;
143
+ }
144
+ .dashboard-message { border-color: #66592e; background: #38321d; color: #f1d98c; }
145
+ .status-failed { background: #4e2927; }
146
+ .status-needs_input { background: #4c3b20; }
147
+ }
@@ -0,0 +1,597 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { EventEmitter } from "node:events";
3
+ import { constants, watch } from "node:fs";
4
+ import { open, readFile, realpath, stat } from "node:fs/promises";
5
+ import http from "node:http";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ import { openWorkspaceInCodex } from "./codex-app.js";
10
+ import {
11
+ canonicalDirectory,
12
+ canonicalGitRoot,
13
+ parseTaskLogContent,
14
+ readConfig,
15
+ } from "./workspace.js";
16
+
17
+ const TASKS_FILE_NAME = "tasks.jsonl";
18
+ const STATIC_ROOT = fileURLToPath(new URL("./dashboard/", import.meta.url));
19
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1"]);
20
+ const DEFAULT_MAX_FILE_BYTES = 16 * 1024 * 1024;
21
+ const DEFAULT_MAX_TASKS = 2_000;
22
+ const DEFAULT_MAX_EVENT_CLIENTS = 16;
23
+ const CONTENT_SECURITY_POLICY = [
24
+ "default-src 'self'",
25
+ "base-uri 'none'",
26
+ "connect-src 'self'",
27
+ "form-action 'none'",
28
+ "frame-ancestors 'none'",
29
+ "img-src 'self' data:",
30
+ "object-src 'none'",
31
+ "script-src 'self'",
32
+ "style-src 'self'",
33
+ ].join("; ");
34
+
35
+ const STATIC_FILES = new Map([
36
+ ["/", ["index.html", "text/html; charset=utf-8"]],
37
+ ["/app.js", ["app.js", "text/javascript; charset=utf-8"]],
38
+ ["/state.js", ["state.js", "text/javascript; charset=utf-8"]],
39
+ ["/styles.css", ["styles.css", "text/css; charset=utf-8"]],
40
+ ]);
41
+
42
+ export function dashboardAuthority(host, port) {
43
+ const address = host === "::1" ? "[::1]" : host;
44
+ return port === 80 ? address : `${address}:${port}`;
45
+ }
46
+
47
+ function meaningfulUpdateTime(task, observedUpdateTimes) {
48
+ return Math.max(
49
+ Date.parse(task.updatedAt ?? task.createdAt),
50
+ observedUpdateTimes.get(task.id) ?? Number.NEGATIVE_INFINITY,
51
+ );
52
+ }
53
+
54
+ export function sortTasksByMeaningfulUpdate(tasks, observedUpdateTimes = new Map()) {
55
+ return tasks
56
+ .map((task, index) => ({ task, index }))
57
+ .sort((left, right) =>
58
+ meaningfulUpdateTime(right.task, observedUpdateTimes)
59
+ - meaningfulUpdateTime(left.task, observedUpdateTimes)
60
+ || left.index - right.index)
61
+ .map(({ task }) => task);
62
+ }
63
+
64
+ function taskFingerprint(tasks) {
65
+ return JSON.stringify(tasks);
66
+ }
67
+
68
+ async function fileFingerprint(filePath) {
69
+ const details = await stat(filePath);
70
+ return statFingerprint(details);
71
+ }
72
+
73
+ function statFingerprint(details) {
74
+ return `${details.dev}:${details.ino}:${details.size}:${details.mtimeMs}`;
75
+ }
76
+
77
+ export async function readBoundedTaskLog(filePath, maximumBytes, { afterOpen = null } = {}) {
78
+ const flags = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0);
79
+ const handle = await open(filePath, flags);
80
+ try {
81
+ const before = await handle.stat();
82
+ if (!before.isFile()) throw new Error("task log is not a regular file");
83
+ if (afterOpen) await afterOpen();
84
+ const chunks = [];
85
+ let total = 0;
86
+ while (total <= maximumBytes) {
87
+ const buffer = Buffer.alloc(Math.min(64 * 1024, maximumBytes + 1 - total));
88
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
89
+ if (bytesRead === 0) break;
90
+ chunks.push(buffer.subarray(0, bytesRead));
91
+ total += bytesRead;
92
+ }
93
+ if (total > maximumBytes) {
94
+ throw new Error(`task log exceeds the dashboard limit of ${maximumBytes} bytes`);
95
+ }
96
+ const after = await handle.stat();
97
+ if (statFingerprint(before) !== statFingerprint(after)) {
98
+ throw new Error("task log changed while the dashboard was reading it");
99
+ }
100
+ return {
101
+ content: Buffer.concat(chunks, total).toString("utf8"),
102
+ fingerprint: statFingerprint(after),
103
+ };
104
+ } finally {
105
+ await handle.close();
106
+ }
107
+ }
108
+
109
+ function boundedText(value, maximum, name) {
110
+ if (value !== null && value !== undefined && String(value).length > maximum) {
111
+ throw new Error(`${name} exceeds the dashboard display limit`);
112
+ }
113
+ }
114
+
115
+ function assertDashboardTaskBounds(tasks, maximumTasks) {
116
+ if (tasks.length > maximumTasks) {
117
+ throw new Error(`task log exceeds the dashboard limit of ${maximumTasks} tasks`);
118
+ }
119
+ for (const [index, task] of tasks.entries()) {
120
+ const name = `task ${index + 1}`;
121
+ boundedText(task.id, 512, `${name} ID`);
122
+ boundedText(task.title, 1_000, `${name} title`);
123
+ boundedText(task.instruction, 250_000, `${name} instruction`);
124
+ boundedText(task.summary, 2_000, `${name} summary`);
125
+ boundedText(task.threadId, 512, `${name} thread ID`);
126
+ boundedText(task.turnId, 512, `${name} turn ID`);
127
+ boundedText(task.project.name, 1_000, `${name} project name`);
128
+ boundedText(task.project.path, 8_192, `${name} project path`);
129
+ boundedText(task.project.description, 4_000, `${name} project description`);
130
+ if (task.project.githubRepos.length > 100) {
131
+ throw new Error(`${name} has too many GitHub repositories for the dashboard`);
132
+ }
133
+ for (const repository of task.project.githubRepos) {
134
+ boundedText(repository, 2_048, `${name} GitHub repository`);
135
+ }
136
+ }
137
+ }
138
+
139
+ export class DashboardMonitor extends EventEmitter {
140
+ constructor(workspace, {
141
+ debounceMs = 75,
142
+ fingerprint = fileFingerprint,
143
+ maxFileBytes = DEFAULT_MAX_FILE_BYTES,
144
+ maxTasks = DEFAULT_MAX_TASKS,
145
+ readSnapshot = null,
146
+ pollIntervalMs = 5_000,
147
+ watchDirectory = watch,
148
+ watchReconnectMs = 1_000,
149
+ } = {}) {
150
+ super();
151
+ this.workspace = workspace;
152
+ this.debounceMs = debounceMs;
153
+ this.pollIntervalMs = pollIntervalMs;
154
+ this.watchDirectory = watchDirectory;
155
+ this.watchReconnectMs = watchReconnectMs;
156
+ this.fingerprint = fingerprint;
157
+ this.maxFileBytes = maxFileBytes;
158
+ this.maxTasks = maxTasks;
159
+ this.readSnapshot = readSnapshot ?? (async () => {
160
+ const snapshot = await readBoundedTaskLog(this.tasksFile, this.maxFileBytes);
161
+ return {
162
+ fingerprint: snapshot.fingerprint,
163
+ tasks: await parseTaskLogContent(this.workspace, snapshot.content),
164
+ };
165
+ });
166
+ this.tasks = [];
167
+ this.revision = 0;
168
+ this.started = false;
169
+ this.currentFingerprint = null;
170
+ this.currentTaskFingerprint = null;
171
+ this.unhealthy = false;
172
+ this.observedUpdateTimes = new Map();
173
+ this.refreshPromise = null;
174
+ this.refreshQueued = false;
175
+ this.watcher = null;
176
+ this.debounceTimer = null;
177
+ this.pollTimer = null;
178
+ this.reconnectTimer = null;
179
+ }
180
+
181
+ snapshot() {
182
+ return {
183
+ schemaVersion: 1,
184
+ revision: this.revision,
185
+ generatedAt: new Date().toISOString(),
186
+ healthy: !this.unhealthy,
187
+ tasks: this.tasks,
188
+ };
189
+ }
190
+
191
+ async start() {
192
+ if (this.started) return this.snapshot();
193
+ this.workspace = await realpath(path.resolve(this.workspace));
194
+ this.tasksFile = path.join(this.workspace, TASKS_FILE_NAME);
195
+ await this.refresh({ force: true });
196
+ if (this.revision === 0) throw new Error("dashboard could not read a valid task log");
197
+ this.started = true;
198
+ this.startWatcher();
199
+ this.pollTimer = setInterval(() => this.refresh(), this.pollIntervalMs);
200
+ this.pollTimer.unref?.();
201
+ return this.snapshot();
202
+ }
203
+
204
+ startWatcher() {
205
+ if (!this.started || this.watcher) return;
206
+ try {
207
+ this.watcher = this.watchDirectory(this.workspace, { persistent: false }, (_event, fileName) => {
208
+ if (fileName !== null && String(fileName) !== TASKS_FILE_NAME) return;
209
+ clearTimeout(this.debounceTimer);
210
+ this.debounceTimer = setTimeout(() => this.refresh({ force: true }), this.debounceMs);
211
+ this.debounceTimer.unref?.();
212
+ });
213
+ this.watcher.on("error", (error) => {
214
+ this.emit("watcherError", error);
215
+ this.watcher?.close();
216
+ this.watcher = null;
217
+ this.scheduleWatcherReconnect();
218
+ });
219
+ } catch (error) {
220
+ this.emit("watcherError", error);
221
+ this.scheduleWatcherReconnect();
222
+ }
223
+ }
224
+
225
+ scheduleWatcherReconnect() {
226
+ if (!this.started || this.reconnectTimer) return;
227
+ this.reconnectTimer = setTimeout(() => {
228
+ this.reconnectTimer = null;
229
+ this.startWatcher();
230
+ this.refresh({ force: true });
231
+ }, this.watchReconnectMs);
232
+ this.reconnectTimer.unref?.();
233
+ }
234
+
235
+ async refresh({ force = false } = {}) {
236
+ if (this.refreshPromise) {
237
+ this.refreshQueued ||= force;
238
+ return this.refreshPromise;
239
+ }
240
+ this.refreshPromise = this.refreshOnce({ force }).finally(async () => {
241
+ this.refreshPromise = null;
242
+ if (this.refreshQueued) {
243
+ this.refreshQueued = false;
244
+ await this.refresh({ force: true });
245
+ }
246
+ });
247
+ return this.refreshPromise;
248
+ }
249
+
250
+ async refreshOnce({ force }) {
251
+ try {
252
+ const observed = await this.fingerprint(this.tasksFile);
253
+ if (!force && observed === this.currentFingerprint) return false;
254
+ const snapshot = await this.readSnapshot();
255
+ assertDashboardTaskBounds(snapshot.tasks, this.maxTasks);
256
+ const after = await this.fingerprint(this.tasksFile);
257
+ if (snapshot.fingerprint !== after) {
258
+ this.refreshQueued = true;
259
+ return false;
260
+ }
261
+ const previous = new Map(this.tasks.map((task) => [task.id, task]));
262
+ const currentIds = new Set(snapshot.tasks.map((task) => task.id));
263
+ for (const taskId of this.observedUpdateTimes.keys()) {
264
+ if (!currentIds.has(taskId)) this.observedUpdateTimes.delete(taskId);
265
+ }
266
+ const observedAt = Date.now();
267
+ for (const task of snapshot.tasks) {
268
+ const former = previous.get(task.id);
269
+ if (former && JSON.stringify(former) !== JSON.stringify(task)) {
270
+ this.observedUpdateTimes.set(task.id, observedAt);
271
+ }
272
+ }
273
+ const tasks = sortTasksByMeaningfulUpdate(snapshot.tasks, this.observedUpdateTimes);
274
+ const nextTaskFingerprint = taskFingerprint(tasks);
275
+ this.currentFingerprint = after;
276
+ if (nextTaskFingerprint === this.currentTaskFingerprint) {
277
+ if (this.unhealthy) {
278
+ this.unhealthy = false;
279
+ this.emit("snapshot", this.snapshot());
280
+ }
281
+ return false;
282
+ }
283
+ this.tasks = tasks;
284
+ this.currentTaskFingerprint = nextTaskFingerprint;
285
+ this.revision += 1;
286
+ this.unhealthy = false;
287
+ this.emit("snapshot", this.snapshot());
288
+ return true;
289
+ } catch (error) {
290
+ if (!this.unhealthy) this.emit("monitorError", error);
291
+ this.unhealthy = true;
292
+ return false;
293
+ }
294
+ }
295
+
296
+ close() {
297
+ this.started = false;
298
+ this.watcher?.close();
299
+ clearTimeout(this.debounceTimer);
300
+ clearTimeout(this.reconnectTimer);
301
+ clearInterval(this.pollTimer);
302
+ this.watcher = null;
303
+ }
304
+ }
305
+
306
+ function securityHeaders(contentType) {
307
+ return {
308
+ "Cache-Control": "no-store",
309
+ "Content-Security-Policy": CONTENT_SECURITY_POLICY,
310
+ "Content-Type": contentType,
311
+ "Cross-Origin-Opener-Policy": "same-origin",
312
+ "Referrer-Policy": "no-referrer",
313
+ "X-Content-Type-Options": "nosniff",
314
+ "X-Frame-Options": "DENY",
315
+ };
316
+ }
317
+
318
+ function sendJson(response, status, value) {
319
+ response.writeHead(status, securityHeaders("application/json; charset=utf-8"));
320
+ response.end(`${JSON.stringify(value)}\n`);
321
+ }
322
+
323
+ function ssePayload(event, value) {
324
+ return `event: ${event}\ndata: ${JSON.stringify(value)}\n\n`;
325
+ }
326
+
327
+ export function writeSseEvent(response, event, value) {
328
+ return response.write(ssePayload(event, value));
329
+ }
330
+
331
+ export function createSseClient(response, {
332
+ drainTimeoutMs = 5_000,
333
+ onClose = () => {},
334
+ } = {}) {
335
+ let blocked = false;
336
+ let closed = false;
337
+ let drainTimer = null;
338
+ let queuedPayload = null;
339
+ const drained = () => {
340
+ blocked = false;
341
+ clearTimeout(drainTimer);
342
+ drainTimer = null;
343
+ const payload = queuedPayload;
344
+ queuedPayload = null;
345
+ if (payload !== null) client.write(payload);
346
+ };
347
+ const client = {
348
+ get blocked() { return blocked; },
349
+ write(payload) {
350
+ if (closed) return false;
351
+ if (blocked) {
352
+ queuedPayload = payload;
353
+ return false;
354
+ }
355
+ const accepted = response.write(payload);
356
+ if (!accepted) {
357
+ blocked = true;
358
+ response.once("drain", drained);
359
+ drainTimer = setTimeout(() => client.close(), drainTimeoutMs);
360
+ drainTimer.unref?.();
361
+ }
362
+ return accepted;
363
+ },
364
+ close() {
365
+ if (closed) return;
366
+ closed = true;
367
+ clearTimeout(drainTimer);
368
+ queuedPayload = null;
369
+ response.off("drain", drained);
370
+ response.destroy();
371
+ onClose();
372
+ },
373
+ };
374
+ return client;
375
+ }
376
+
377
+ function requestSessionCookie(request, cookieName) {
378
+ return request.headers.cookie
379
+ ?.split(";")
380
+ .map((part) => part.trim())
381
+ .find((part) => part.startsWith(`${cookieName}=`))
382
+ ?.slice(cookieName.length + 1) ?? null;
383
+ }
384
+
385
+ function publicMonitorError() {
386
+ return {
387
+ message: "The task log is temporarily unavailable. Showing the last valid snapshot.",
388
+ };
389
+ }
390
+
391
+ export async function createDashboardServer({
392
+ workspace,
393
+ host = "127.0.0.1",
394
+ maxEventClients = DEFAULT_MAX_EVENT_CLIENTS,
395
+ port = 3210,
396
+ monitorOptions = {},
397
+ openProject = null,
398
+ } = {}) {
399
+ if (!LOOPBACK_HOSTS.has(host)) {
400
+ throw new Error("dashboard host must be a loopback address");
401
+ }
402
+ if (!Number.isInteger(port) || port < 0 || port > 65_535) {
403
+ throw new Error("dashboard port must be an integer from 0 to 65535");
404
+ }
405
+ if (!Number.isInteger(maxEventClients) || maxEventClients < 0) {
406
+ throw new Error("dashboard event-client limit must be a non-negative integer");
407
+ }
408
+ const monitor = new DashboardMonitor(workspace, monitorOptions);
409
+ await monitor.start();
410
+ const clients = new Set();
411
+ const capabilityToken = randomBytes(32).toString("base64url");
412
+ let launchToken = capabilityToken;
413
+ const sessionToken = randomBytes(32).toString("base64url");
414
+ const sessionCookieName = `taskchef_session_${randomBytes(12).toString("hex")}`;
415
+ let allowedAuthority;
416
+ let allowedOrigin;
417
+
418
+ const broadcast = (event, value) => {
419
+ const payload = ssePayload(event, value);
420
+ for (const client of clients) client.write(payload);
421
+ };
422
+ const snapshotListener = (snapshot) => broadcast("snapshot", snapshot);
423
+ const errorListener = () => broadcast("dashboard-error", publicMonitorError());
424
+ monitor.on("snapshot", snapshotListener);
425
+ monitor.on("monitorError", errorListener);
426
+
427
+ const handleRequest = async (request, response) => {
428
+ const method = request.method ?? "GET";
429
+ let url;
430
+ try {
431
+ url = new URL(request.url ?? "/", "http://localhost");
432
+ } catch {
433
+ sendJson(response, 400, { message: "Malformed request target." });
434
+ return;
435
+ }
436
+ if (request.headers.host !== allowedAuthority) {
437
+ sendJson(response, 421, { message: "Misdirected request." });
438
+ return;
439
+ }
440
+ const authenticated = requestSessionCookie(request, sessionCookieName) === sessionToken;
441
+ if (
442
+ (method === "GET" || method === "HEAD")
443
+ && launchToken !== null
444
+ && url.pathname === "/"
445
+ && url.searchParams.get("token") === launchToken
446
+ ) {
447
+ launchToken = null;
448
+ response.writeHead(303, {
449
+ ...securityHeaders("text/plain; charset=utf-8"),
450
+ Location: "/",
451
+ "Set-Cookie": `${sessionCookieName}=${sessionToken}; HttpOnly; SameSite=Strict; Path=/`,
452
+ });
453
+ response.end("Opening TaskChef dashboard.\n");
454
+ return;
455
+ }
456
+ if (!authenticated) {
457
+ sendJson(response, 401, { message: "Dashboard launch capability required." });
458
+ return;
459
+ }
460
+ if (method !== "GET" && method !== "HEAD" && method !== "POST") {
461
+ response.writeHead(405, { Allow: "GET, HEAD, POST" });
462
+ response.end();
463
+ return;
464
+ }
465
+
466
+ if (url.pathname === "/api/snapshot" && (method === "GET" || method === "HEAD")) {
467
+ if (method === "HEAD") {
468
+ response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
469
+ response.end();
470
+ } else {
471
+ sendJson(response, 200, monitor.snapshot());
472
+ }
473
+ return;
474
+ }
475
+
476
+ if (url.pathname === "/api/events" && method === "GET") {
477
+ if (clients.size >= maxEventClients) {
478
+ sendJson(response, 503, { message: "Dashboard event-stream limit reached." });
479
+ return;
480
+ }
481
+ response.writeHead(200, {
482
+ ...securityHeaders("text/event-stream; charset=utf-8"),
483
+ Connection: "keep-alive",
484
+ });
485
+ let client;
486
+ client = createSseClient(response, {
487
+ onClose: () => {
488
+ clearInterval(client.heartbeat);
489
+ clients.delete(client);
490
+ },
491
+ });
492
+ clients.add(client);
493
+ client.write(`retry: 2000\n\n${ssePayload("snapshot", monitor.snapshot())}`);
494
+ client.heartbeat = setInterval(() => {
495
+ if (!client.blocked) client.write(": heartbeat\n\n");
496
+ }, 15_000);
497
+ const { heartbeat } = client;
498
+ heartbeat.unref?.();
499
+ request.on("close", () => client.close());
500
+ response.on("error", () => client.close());
501
+ return;
502
+ }
503
+
504
+ const taskMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)\/open-project$/);
505
+ if (taskMatch && method === "POST") {
506
+ if (request.headers.origin !== allowedOrigin) {
507
+ sendJson(response, 403, { message: "Dashboard session validation failed." });
508
+ return;
509
+ }
510
+ const task = monitor.tasks.find((candidate) => candidate.id === taskMatch[1]);
511
+ if (!task) {
512
+ sendJson(response, 404, { message: "Task not found." });
513
+ return;
514
+ }
515
+ try {
516
+ const trustedProject = (await readConfig(monitor.workspace, { checkPaths: false })).projects
517
+ .find((project) => project.path === task.project.path);
518
+ if (!trustedProject) {
519
+ sendJson(response, 409, {
520
+ message: "This historical task no longer matches a configured project.",
521
+ });
522
+ return;
523
+ }
524
+ const canonicalProjectPath = trustedProject.isGitRepository
525
+ ? await canonicalGitRoot(trustedProject.path)
526
+ : await canonicalDirectory(trustedProject.path);
527
+ if (canonicalProjectPath !== trustedProject.path) {
528
+ sendJson(response, 409, {
529
+ message: "This configured project has moved. Repair the TaskChef project first.",
530
+ });
531
+ return;
532
+ }
533
+ if (openProject) await openProject(canonicalProjectPath);
534
+ else await openWorkspaceInCodex(canonicalProjectPath);
535
+ sendJson(response, 202, {
536
+ message: "Opened the project in Codex. Select the recorded task there.",
537
+ });
538
+ } catch {
539
+ sendJson(response, 503, {
540
+ message: "Codex could not be opened. Run codex app with the project path instead.",
541
+ });
542
+ }
543
+ return;
544
+ }
545
+
546
+ if (method === "POST") {
547
+ sendJson(response, 404, { message: "Not found." });
548
+ return;
549
+ }
550
+
551
+ const staticFile = STATIC_FILES.get(url.pathname);
552
+ if (!staticFile) {
553
+ sendJson(response, 404, { message: "Not found." });
554
+ return;
555
+ }
556
+ const [fileName, contentType] = staticFile;
557
+ const body = await readFile(path.join(STATIC_ROOT, fileName));
558
+ response.writeHead(200, securityHeaders(contentType));
559
+ response.end(method === "HEAD" ? undefined : body);
560
+ };
561
+
562
+ const server = http.createServer((request, response) => {
563
+ handleRequest(request, response).catch(() => {
564
+ if (response.headersSent) response.destroy();
565
+ else sendJson(response, 500, { message: "Dashboard request failed." });
566
+ });
567
+ });
568
+
569
+ await new Promise((resolve, reject) => {
570
+ server.once("error", reject);
571
+ server.listen(port, host, resolve);
572
+ }).catch((error) => {
573
+ monitor.close();
574
+ throw error;
575
+ });
576
+
577
+ const address = server.address();
578
+ const boundPort = typeof address === "object" && address ? address.port : port;
579
+ allowedAuthority = dashboardAuthority(host, boundPort);
580
+ allowedOrigin = `http://${allowedAuthority}`;
581
+ return {
582
+ host,
583
+ port: boundPort,
584
+ origin: allowedOrigin,
585
+ url: `${allowedOrigin}/?token=${encodeURIComponent(capabilityToken)}`,
586
+ monitor,
587
+ get eventClientCount() { return clients.size; },
588
+ async close() {
589
+ monitor.off("snapshot", snapshotListener);
590
+ monitor.off("monitorError", errorListener);
591
+ monitor.close();
592
+ for (const client of clients) client.close();
593
+ await new Promise((resolve, reject) => server.close((error) =>
594
+ error ? reject(error) : resolve()));
595
+ },
596
+ };
597
+ }
package/src/workspace.js CHANGED
@@ -852,13 +852,8 @@ async function validateDispatchShape(
852
852
  return normalized;
853
853
  }
854
854
 
855
- async function readDispatchRecordsUnlocked(root) {
855
+ async function parseDispatchRecordsUnlocked(root, content) {
856
856
  await readConfig(root, { checkPaths: false });
857
- const filePath = path.join(root, DISPATCH_FILE_NAME);
858
- if (!(await managedRegularFileExists(filePath))) {
859
- throw new Error(`task log does not exist: ${filePath}`);
860
- }
861
- const content = await readFile(filePath, "utf8");
862
857
  if (content.length > 0 && !content.endsWith("\n")) {
863
858
  throw new Error(`${DISPATCH_FILE_NAME} must end with a newline`);
864
859
  }
@@ -895,6 +890,14 @@ async function readDispatchRecordsUnlocked(root) {
895
890
  return records;
896
891
  }
897
892
 
893
+ async function readDispatchRecordsUnlocked(root) {
894
+ const filePath = path.join(root, DISPATCH_FILE_NAME);
895
+ if (!(await managedRegularFileExists(filePath))) {
896
+ throw new Error(`task log does not exist: ${filePath}`);
897
+ }
898
+ return parseDispatchRecordsUnlocked(root, await readFile(filePath, "utf8"));
899
+ }
900
+
898
901
  async function readDispatchesUnlocked(root) {
899
902
  return (await readDispatchRecordsUnlocked(root)).map((record) => record.normalized);
900
903
  }
@@ -904,6 +907,12 @@ export async function listTasks(workspaceRoot) {
904
907
  return readDispatchesUnlocked(root);
905
908
  }
906
909
 
910
+ export async function parseTaskLogContent(workspaceRoot, content) {
911
+ const root = await realpath(path.resolve(workspaceRoot));
912
+ if (typeof content !== "string") throw new Error("task log content must be a string");
913
+ return (await parseDispatchRecordsUnlocked(root, content)).map((record) => record.normalized);
914
+ }
915
+
907
916
  export async function recordTask(workspaceRoot, input, { now } = {}) {
908
917
  requireExactFields(input, RECORD_DISPATCH_FIELDS, "task input");
909
918
  const root = await realpath(path.resolve(workspaceRoot));
@@ -942,7 +951,7 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
942
951
  });
943
952
  }
944
953
 
945
- export async function resolveTask(workspaceRoot, taskId, threadId) {
954
+ export async function resolveTask(workspaceRoot, taskId, threadId, { now } = {}) {
946
955
  const id = requireSafeId(taskId, "taskId");
947
956
  const durableThreadId = normalizeDurableThreadId(threadId);
948
957
  const root = await realpath(path.resolve(workspaceRoot));
@@ -962,9 +971,19 @@ export async function resolveTask(workspaceRoot, taskId, threadId) {
962
971
  if (dispatches.some((item) => item.threadId === durableThreadId)) {
963
972
  throw new Error(`threadId is already recorded: ${durableThreadId}`);
964
973
  }
965
- const resolved = { ...dispatch, threadId: durableThreadId };
974
+ const currentRecord = records[index];
975
+ const resolved = currentRecord.raw.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
976
+ ? await validateDispatchShape({
977
+ ...dispatch,
978
+ threadId: durableThreadId,
979
+ updatedAt: now ?? new Date().toISOString(),
980
+ updatedBy: "dispatcher",
981
+ })
982
+ : { ...dispatch, threadId: durableThreadId };
966
983
  const lines = records.map((record, recordIndex) => recordIndex === index
967
- ? JSON.stringify({ ...record.raw, threadId: durableThreadId })
984
+ ? currentRecord.raw.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
985
+ ? dispatchLineWithState(resolved, {})
986
+ : JSON.stringify({ ...record.raw, threadId: durableThreadId })
968
987
  : record.line);
969
988
  await writeDispatchLinesAtomic(root, lines);
970
989
  return resolved;