taskchef 7.4.0 → 7.6.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.
@@ -8,6 +8,7 @@ import {
8
8
  taskWithinDateFilter,
9
9
  } from "./state.js";
10
10
  import { openTaskFromControl } from "./actions.js";
11
+ import { RelativeTimeController, parsedTimestamp } from "./time.js";
11
12
 
12
13
  const state = {
13
14
  tasks: [],
@@ -17,6 +18,8 @@ const state = {
17
18
  selectedTask: null,
18
19
  };
19
20
  let dateRefreshTimer = null;
21
+ let detailRequestGeneration = 0;
22
+ const relativeTimes = new RelativeTimeController();
20
23
 
21
24
  const elements = {
22
25
  clearNotifications: document.querySelector("#clear-notifications"),
@@ -31,7 +34,7 @@ const elements = {
31
34
  dialogInstruction: document.querySelector("#dialog-instruction"),
32
35
  dialogMetadata: document.querySelector("#dialog-metadata"),
33
36
  dialogProject: document.querySelector("#dialog-project"),
34
- dialogSummary: document.querySelector("#dialog-summary"),
37
+ dialogResults: document.querySelector("#dialog-results"),
35
38
  dialogTitle: document.querySelector("#dialog-title"),
36
39
  dismissDashboardMessage: document.querySelector("#dismiss-dashboard-message"),
37
40
  emptyState: document.querySelector("#empty-state"),
@@ -44,12 +47,42 @@ const elements = {
44
47
  toastList: document.querySelector("#toast-list"),
45
48
  };
46
49
 
47
- function formatTime(value) {
48
- if (!value) return "—";
49
- return new Intl.DateTimeFormat(undefined, {
50
- dateStyle: "medium",
51
- timeStyle: "short",
52
- }).format(new Date(value));
50
+ function timestampControl(value, { accessibleName, key, prefix = "" }) {
51
+ if (!parsedTimestamp(value)) {
52
+ const missing = document.createElement("span");
53
+ missing.className = "timestamp-missing";
54
+ missing.textContent = `${prefix}—`;
55
+ return missing;
56
+ }
57
+ const control = document.createElement("button");
58
+ control.type = "button";
59
+ control.className = "timestamp-toggle";
60
+ const time = document.createElement("time");
61
+ control.append(time);
62
+ relativeTimes.register(key, value, ({ exact, iso, label }) => {
63
+ time.dateTime = iso;
64
+ time.textContent = `${prefix}${label}`;
65
+ const action = exact ? "Show relative time" : "Show exact date and time";
66
+ control.title = action;
67
+ control.setAttribute("aria-label", `${accessibleName}: ${label}. ${action}.`);
68
+ }, { isActive: () => control.isConnected });
69
+ control.addEventListener("click", () => relativeTimes.toggle(key));
70
+ return control;
71
+ }
72
+
73
+ function codexIcon() {
74
+ const icon = document.createElement("span");
75
+ icon.className = "codex-icon";
76
+ icon.setAttribute("aria-hidden", "true");
77
+ return icon;
78
+ }
79
+
80
+ function configureOpenTaskControl(control, ariaLabel) {
81
+ control.classList.add("task-action");
82
+ control.setAttribute("aria-label", ariaLabel);
83
+ const label = document.createElement("span");
84
+ label.textContent = "Open task";
85
+ control.replaceChildren(codexIcon(), label);
53
86
  }
54
87
 
55
88
  function setConnection(connected) {
@@ -115,16 +148,55 @@ function detailRow(term, value) {
115
148
  const dt = document.createElement("dt");
116
149
  dt.textContent = term;
117
150
  const dd = document.createElement("dd");
118
- dd.textContent = value ?? "—";
151
+ if (value instanceof Node) dd.append(value);
152
+ else dd.textContent = value ?? "—";
119
153
  return [dt, dd];
120
154
  }
121
155
 
122
- function openDialog(task) {
123
- state.selectedTask = task;
156
+ function resultHistory(taskId, results) {
157
+ if (results.length === 0) {
158
+ const empty = document.createElement("p");
159
+ empty.className = "result-history-empty";
160
+ empty.textContent = "No semantic result has been reported yet.";
161
+ return [empty];
162
+ }
163
+ return [...results].reverse().map((result, index) => {
164
+ const item = document.createElement("article");
165
+ item.className = `result-history-item${index === 0 ? " result-history-latest" : ""}`;
166
+ const header = document.createElement("div");
167
+ header.className = "result-history-header";
168
+ const status = document.createElement("span");
169
+ status.className = `status status-${result.status}`;
170
+ status.textContent = result.status.replaceAll("_", " ");
171
+ const resultKey = result.turnId ?? `${result.status}:${index}`;
172
+ const timestamp = timestampControl(result.updatedAt, {
173
+ accessibleName: `Result updated time for ${result.status.replaceAll("_", " ")}`,
174
+ key: `detail:${taskId}:result:${resultKey}`,
175
+ });
176
+ header.append(status, timestamp);
177
+ const summary = document.createElement("p");
178
+ summary.className = "preserve-lines";
179
+ summary.textContent = result.summary;
180
+ const turn = document.createElement("p");
181
+ turn.className = "result-history-turn";
182
+ turn.textContent = result.turnId ? `Turn ${result.turnId}` : "No turn ID (creation failure)";
183
+ item.append(header, summary, turn);
184
+ return item;
185
+ });
186
+ }
187
+
188
+ function renderDialog(task) {
189
+ const preservedResults = state.selectedTask?.id === task.id
190
+ ? state.selectedTask.results
191
+ : null;
192
+ const detailedTask = {
193
+ ...task,
194
+ results: task.results ?? preservedResults ?? [],
195
+ };
196
+ state.selectedTask = detailedTask;
124
197
  elements.dialogProject.textContent = task.project.name;
125
198
  elements.dialogTitle.textContent = task.title;
126
- elements.dialogSummary.textContent = task.lastResult?.summary
127
- ?? "No semantic result has been reported yet.";
199
+ elements.dialogResults.replaceChildren(...resultHistory(task.id, detailedTask.results));
128
200
  elements.dialogInstruction.textContent = task.instruction;
129
201
  elements.copyThreadId.disabled = !task.threadId;
130
202
  elements.dialogMetadata.replaceChildren(
@@ -132,17 +204,49 @@ function openDialog(task) {
132
204
  ...detailRow("Current turn ID", task.turnId),
133
205
  ...detailRow("Last result status", task.lastResult?.status?.replaceAll("_", " ")),
134
206
  ...detailRow("Last result turn ID", task.lastResult?.turnId),
135
- ...detailRow("Last result updated", formatTime(task.lastResult?.updatedAt)),
207
+ ...detailRow("Last result updated", timestampControl(task.lastResult?.updatedAt, {
208
+ accessibleName: `Last result updated time for ${task.title}`,
209
+ key: `detail:${task.id}:last-result-updated`,
210
+ })),
136
211
  ...detailRow("Task ID", task.id),
137
212
  ...detailRow("Thread ID", task.threadId),
138
213
  ...detailRow("Project path", task.project.path),
139
- ...detailRow("Created", formatTime(task.createdAt)),
140
- ...detailRow("Updated", formatTime(
214
+ ...detailRow("Created", timestampControl(task.createdAt, {
215
+ accessibleName: `Created time for ${task.title}`,
216
+ key: `detail:${task.id}:created`,
217
+ })),
218
+ ...detailRow("Updated", timestampControl(
141
219
  task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt,
220
+ {
221
+ accessibleName: `Updated time for ${task.title}`,
222
+ key: `detail:${task.id}:updated`,
223
+ },
142
224
  )),
143
225
  ...detailRow("Updated by", task.updatedBy),
144
226
  );
227
+ configureOpenTaskControl(elements.openProject, `Open ${task.title} in Codex`);
228
+ }
229
+
230
+ async function openDialog(task) {
231
+ const requestGeneration = ++detailRequestGeneration;
232
+ renderDialog(task);
145
233
  if (!elements.dialog.open) elements.dialog.showModal();
234
+ try {
235
+ const response = await fetch(`/api/tasks/${encodeURIComponent(task.id)}`);
236
+ if (!response.ok) throw new Error("Task details are unavailable.");
237
+ const detail = await response.json();
238
+ if (
239
+ requestGeneration === detailRequestGeneration
240
+ && state.selectedTask?.id === task.id
241
+ && elements.dialog.open
242
+ ) {
243
+ renderDialog(detail.task);
244
+ }
245
+ } catch {
246
+ if (state.selectedTask?.id === task.id) {
247
+ showMessage("Task result history is temporarily unavailable.");
248
+ }
249
+ }
146
250
  }
147
251
 
148
252
  function taskCard(task) {
@@ -165,16 +269,20 @@ function taskCard(task) {
165
269
  const summary = document.createElement("p");
166
270
  summary.className = "task-summary";
167
271
  summary.textContent = task.lastResult?.summary ?? "No semantic result reported yet.";
168
- const time = document.createElement("time");
169
- time.dateTime = task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt;
170
- time.textContent = `Updated ${formatTime(time.dateTime)}`;
272
+ const time = timestampControl(
273
+ task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt,
274
+ {
275
+ accessibleName: `Updated time for ${task.title}`,
276
+ key: `list:${task.id}:updated`,
277
+ prefix: "Updated ",
278
+ },
279
+ );
171
280
  const footer = document.createElement("div");
172
281
  footer.className = "task-footer";
173
282
  const openTask = document.createElement("button");
174
283
  openTask.type = "button";
175
284
  openTask.className = "secondary-button task-open";
176
- openTask.textContent = "Open task";
177
- openTask.setAttribute("aria-label", `Open ${task.title} in Codex`);
285
+ configureOpenTaskControl(openTask, `Open ${task.title} in Codex`);
178
286
  openTask.addEventListener("click", (event) => openTaskFromControl(event, task.id, {
179
287
  showMessage,
180
288
  }));
@@ -83,12 +83,15 @@
83
83
  <button id="close-dialog" class="icon-button" type="button" aria-label="Close task details">×</button>
84
84
  </div>
85
85
  <div class="dialog-actions">
86
- <button id="open-codex" class="primary-button" type="button">Open task in Codex</button>
86
+ <button id="open-codex" class="primary-button task-action" type="button" aria-label="Open this task in Codex">
87
+ <span class="codex-icon" aria-hidden="true"></span>
88
+ <span>Open task</span>
89
+ </button>
87
90
  <button id="copy-thread-id" class="secondary-button" type="button">Copy thread ID</button>
88
91
  </div>
89
92
  <section>
90
- <h3>Result</h3>
91
- <p id="dialog-summary" class="preserve-lines"></p>
93
+ <h3>Result history</h3>
94
+ <div id="dialog-results" class="result-history"></div>
92
95
  </section>
93
96
  <section>
94
97
  <h3>Original instruction</h3>
@@ -51,7 +51,7 @@ h3 { margin-bottom: 8px; font-size: 0.84rem; letter-spacing: 0.08em; text-transf
51
51
  .dashboard-icon img { display: block; width: 100%; height: 100%; object-fit: contain; }
52
52
 
53
53
  .eyebrow { margin-bottom: 10px; color: var(--accent); font-size: 0.78rem; font-weight: 750; letter-spacing: 0.15em; text-transform: uppercase; }
54
- .subtitle, .task-project, time { color: var(--muted); }
54
+ .subtitle, .task-project, time, .timestamp-missing { color: var(--muted); }
55
55
  .subtitle { margin-bottom: 0; }
56
56
 
57
57
  .connection { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 0.9rem; }
@@ -78,9 +78,13 @@ select { min-width: 180px; padding: 9px 34px 9px 11px; border: 1px solid var(--b
78
78
  .task-title:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
79
79
  .task-project { margin: 3px 0 12px; font-size: 0.86rem; }
80
80
  .task-summary { max-width: 78ch; margin-bottom: 14px; }
81
- time { font-size: 0.78rem; }
81
+ time, .timestamp-missing { font-size: 0.78rem; }
82
+ .timestamp-toggle { padding: 2px 0; border: 0; background: none; cursor: pointer; text-align: left; }
83
+ .timestamp-toggle:hover time { color: var(--text); text-decoration: underline; text-underline-offset: 3px; }
82
84
  .task-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
83
85
  .task-open { flex: none; }
86
+ .task-action { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 32px; padding: 5px 9px; font-size: 0.82rem; line-height: 1.2; white-space: nowrap; }
87
+ .codex-icon { display: inline-block; flex: none; width: 16px; height: 16px; background: currentColor; mask: url("/assets/codex.svg") center / contain no-repeat; }
84
88
 
85
89
  .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; }
86
90
  .status-completed { background: var(--accent-soft); color: var(--accent); }
@@ -117,6 +121,14 @@ dialog::backdrop { background: rgb(18 23 21 / 50%); backdrop-filter: blur(2px);
117
121
  .primary-button:disabled, .secondary-button:disabled { opacity: 0.5; cursor: not-allowed; }
118
122
  dialog section + section { margin-top: 24px; }
119
123
  .preserve-lines { white-space: pre-wrap; }
124
+ .result-history { display: grid; gap: 10px; }
125
+ .result-history-item { padding: 13px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-muted); }
126
+ .result-history-latest { border-color: var(--accent); background: var(--accent-soft); box-shadow: inset 3px 0 var(--accent); }
127
+ .result-history-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
128
+ .result-history-item p { margin-bottom: 7px; }
129
+ .result-history-item p:last-child { margin-bottom: 0; }
130
+ .result-history-turn, .result-history-empty { color: var(--muted); font-size: 0.78rem; overflow-wrap: anywhere; }
131
+ .result-history-empty { margin: 0; }
120
132
  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; }
121
133
  .metadata { display: grid; grid-template-columns: minmax(100px, 150px) 1fr; margin: 0; font-size: 0.88rem; }
122
134
  .metadata dt, .metadata dd { padding: 7px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
@@ -134,8 +146,8 @@ pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius
134
146
  .task-count { margin: 0; }
135
147
  .task-heading { align-items: flex-start; }
136
148
  .task-footer { align-items: flex-start; flex-direction: column; }
137
- .task-open { width: 100%; }
138
- .dialog-actions { align-items: stretch; flex-direction: column; }
149
+ .task-open { align-self: flex-start; }
150
+ .dialog-actions { align-items: center; flex-flow: row wrap; }
139
151
  .metadata { grid-template-columns: 1fr; }
140
152
  .metadata dt { padding-bottom: 0; border-bottom: 0; }
141
153
  }
@@ -0,0 +1,146 @@
1
+ export const RELATIVE_TIME_REFRESH_MS = 30_000;
2
+ export const RELATIVE_DATE_LIMIT_DAYS = 30;
3
+
4
+ const MINUTE_MS = 60_000;
5
+ const HOUR_MS = 60 * MINUTE_MS;
6
+ const DAY_MS = 24 * HOUR_MS;
7
+
8
+ export function parsedTimestamp(value) {
9
+ if (typeof value !== "string" || value.trim() === "") return null;
10
+ const milliseconds = Date.parse(value);
11
+ if (!Number.isFinite(milliseconds)) return null;
12
+ return {
13
+ date: new Date(milliseconds),
14
+ iso: new Date(milliseconds).toISOString(),
15
+ milliseconds,
16
+ };
17
+ }
18
+
19
+ export function formatExactTime(value, { locale, timeZone } = {}) {
20
+ const parsed = parsedTimestamp(value);
21
+ if (!parsed) return "—";
22
+ return new Intl.DateTimeFormat(locale, {
23
+ dateStyle: "medium",
24
+ timeStyle: "short",
25
+ timeZone,
26
+ }).format(parsed.date);
27
+ }
28
+
29
+ function hourPhrase(milliseconds, direction) {
30
+ const hours = Math.floor(milliseconds / HOUR_MS);
31
+ const minutes = Math.floor((milliseconds % HOUR_MS) / MINUTE_MS);
32
+ const hourText = hours === 1 ? "1 hour" : `${hours} hours`;
33
+ const detail = hours < 6 && minutes > 0 ? ` ${minutes} minutes` : "";
34
+ return direction === "future"
35
+ ? `in ${hourText}${detail}`
36
+ : `${hourText}${detail} ago`;
37
+ }
38
+
39
+ export function formatRelativeTime(value, {
40
+ now = Date.now(),
41
+ locale,
42
+ timeZone,
43
+ } = {}) {
44
+ const parsed = parsedTimestamp(value);
45
+ if (!parsed) return "—";
46
+ const difference = now - parsed.milliseconds;
47
+ const future = difference < 0;
48
+ const elapsed = Math.abs(difference);
49
+ if (elapsed < MINUTE_MS) return "just now";
50
+ if (elapsed < 2 * MINUTE_MS) return future ? "in 1 minute" : "1 minute ago";
51
+ if (elapsed < HOUR_MS) {
52
+ const minutes = future
53
+ ? Math.ceil(elapsed / MINUTE_MS)
54
+ : Math.floor(elapsed / MINUTE_MS);
55
+ return future ? `in ${minutes} minutes` : `${minutes} minutes ago`;
56
+ }
57
+ if (elapsed < 2 * HOUR_MS) return future ? "in 1 hour" : "1 hour ago";
58
+ if (elapsed < DAY_MS) return hourPhrase(elapsed, future ? "future" : "past");
59
+ if (elapsed < 2 * DAY_MS) return future ? "in 1 day" : "1 day ago";
60
+ if (elapsed < RELATIVE_DATE_LIMIT_DAYS * DAY_MS) {
61
+ const days = future
62
+ ? Math.ceil(elapsed / DAY_MS)
63
+ : Math.floor(elapsed / DAY_MS);
64
+ return future ? `in ${days} days` : `${days} days ago`;
65
+ }
66
+ return new Intl.DateTimeFormat(locale, {
67
+ dateStyle: "medium",
68
+ timeZone,
69
+ }).format(parsed.date);
70
+ }
71
+
72
+ export function timestampPresentation(value, {
73
+ exact = false,
74
+ now = Date.now(),
75
+ locale,
76
+ timeZone,
77
+ } = {}) {
78
+ const parsed = parsedTimestamp(value);
79
+ if (!parsed) return { valid: false, label: "—", iso: null };
80
+ return {
81
+ exact,
82
+ iso: parsed.iso,
83
+ label: exact
84
+ ? formatExactTime(value, { locale, timeZone })
85
+ : formatRelativeTime(value, { now, locale, timeZone }),
86
+ valid: true,
87
+ };
88
+ }
89
+
90
+ export class RelativeTimeController {
91
+ constructor({
92
+ now = () => Date.now(),
93
+ setIntervalFn = (...args) => globalThis.setInterval(...args),
94
+ clearIntervalFn = (...args) => globalThis.clearInterval(...args),
95
+ refreshEveryMs = RELATIVE_TIME_REFRESH_MS,
96
+ } = {}) {
97
+ this.now = now;
98
+ this.setIntervalFn = setIntervalFn;
99
+ this.clearIntervalFn = clearIntervalFn;
100
+ this.refreshEveryMs = refreshEveryMs;
101
+ this.entries = new Map();
102
+ this.exactKeys = new Set();
103
+ this.timer = null;
104
+ }
105
+
106
+ register(key, value, render, { isActive = () => true } = {}) {
107
+ const token = Symbol(key);
108
+ this.entries.set(token, { isActive, key, render, value });
109
+ this.renderEntry(this.entries.get(token));
110
+ if (this.timer === null) {
111
+ this.timer = this.setIntervalFn(() => this.refresh(), this.refreshEveryMs);
112
+ }
113
+ return () => this.entries.delete(token);
114
+ }
115
+
116
+ renderEntry(entry) {
117
+ entry.render(timestampPresentation(entry.value, {
118
+ exact: this.exactKeys.has(entry.key),
119
+ now: this.now(),
120
+ }));
121
+ }
122
+
123
+ toggle(key) {
124
+ if (this.exactKeys.has(key)) this.exactKeys.delete(key);
125
+ else this.exactKeys.add(key);
126
+ for (const entry of this.entries.values()) {
127
+ if (entry.key === key) this.renderEntry(entry);
128
+ }
129
+ }
130
+
131
+ refresh() {
132
+ for (const [token, entry] of this.entries) {
133
+ if (!entry.isActive()) {
134
+ this.entries.delete(token);
135
+ continue;
136
+ }
137
+ if (!this.exactKeys.has(entry.key)) this.renderEntry(entry);
138
+ }
139
+ }
140
+
141
+ stop() {
142
+ if (this.timer !== null) this.clearIntervalFn(this.timer);
143
+ this.timer = null;
144
+ this.entries.clear();
145
+ }
146
+ }
package/src/dashboard.js CHANGED
@@ -43,10 +43,12 @@ const STATIC_FILES = new Map([
43
43
  ["/", [path.join(STATIC_ROOT, "index.html"), "text/html; charset=utf-8"]],
44
44
  ["/actions.js", [path.join(STATIC_ROOT, "actions.js"), "text/javascript; charset=utf-8"]],
45
45
  ["/app.js", [path.join(STATIC_ROOT, "app.js"), "text/javascript; charset=utf-8"]],
46
+ ["/time.js", [path.join(STATIC_ROOT, "time.js"), "text/javascript; charset=utf-8"]],
46
47
  ["/state.js", [path.join(STATIC_ROOT, "state.js"), "text/javascript; charset=utf-8"]],
47
48
  ["/styles.css", [path.join(STATIC_ROOT, "styles.css"), "text/css; charset=utf-8"]],
48
49
  ["/assets/taskchef-dark.svg", [path.join(ASSET_ROOT, "taskchef-dark.svg"), "image/svg+xml"]],
49
50
  ["/assets/taskchef.svg", [path.join(ASSET_ROOT, "taskchef.svg"), "image/svg+xml"]],
51
+ ["/assets/codex.svg", [path.join(ASSET_ROOT, "codex.svg"), "image/svg+xml"]],
50
52
  ]);
51
53
 
52
54
  export function dashboardAuthority(host, port) {
@@ -136,6 +138,14 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
136
138
  boundedText(task.turnId, 512, `${name} turn ID`);
137
139
  boundedText(task.lastResult?.summary, 2_000, `${name} last result summary`);
138
140
  boundedText(task.lastResult?.turnId, 512, `${name} last result turn ID`);
141
+ const results = task.results ?? [];
142
+ if (results.length > 10_000) {
143
+ throw new Error(`${name} has too many results for the dashboard`);
144
+ }
145
+ for (const [resultIndex, result] of results.entries()) {
146
+ boundedText(result.summary, 2_000, `${name} result ${resultIndex + 1} summary`);
147
+ boundedText(result.turnId, 512, `${name} result ${resultIndex + 1} turn ID`);
148
+ }
139
149
  boundedText(task.project.name, 1_000, `${name} project name`);
140
150
  boundedText(task.project.path, 8_192, `${name} project path`);
141
151
  boundedText(task.project.description, 4_000, `${name} project description`);
@@ -148,6 +158,11 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
148
158
  }
149
159
  }
150
160
 
161
+ function taskListProjection(task) {
162
+ const { results: _results, ...projection } = task;
163
+ return projection;
164
+ }
165
+
151
166
  export class DashboardMonitor extends EventEmitter {
152
167
  constructor(workspace, {
153
168
  debounceMs = 75,
@@ -197,7 +212,7 @@ export class DashboardMonitor extends EventEmitter {
197
212
  generatedAt: new Date().toISOString(),
198
213
  healthy: !this.unhealthy,
199
214
  tasks: this.tasks.map((task) => ({
200
- ...task,
215
+ ...taskListProjection(task),
201
216
  meaningfulUpdatedAt: new Date(
202
217
  meaningfulUpdateTime(task, this.observedUpdateTimes),
203
218
  ).toISOString(),
@@ -510,6 +525,22 @@ export async function createDashboardServer({
510
525
  return;
511
526
  }
512
527
 
528
+ const detailMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)$/);
529
+ if (detailMatch && (method === "GET" || method === "HEAD")) {
530
+ const task = monitor.tasks.find((candidate) => candidate.id === detailMatch[1]);
531
+ if (!task) {
532
+ sendJson(response, 404, { message: "Task not found." });
533
+ return;
534
+ }
535
+ if (method === "HEAD") {
536
+ response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
537
+ response.end();
538
+ } else {
539
+ sendJson(response, 200, { schemaVersion: 1, task });
540
+ }
541
+ return;
542
+ }
543
+
513
544
  const taskMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)\/open-codex$/);
514
545
  if (taskMatch && method === "POST") {
515
546
  if (request.headers.origin !== allowedOrigin) {
package/src/mcp.js CHANGED
@@ -21,7 +21,7 @@ const projectSchema = z.object({
21
21
  });
22
22
 
23
23
  const taskSchema = z.object({
24
- schemaVersion: z.union([z.literal(4), z.literal(5)]),
24
+ schemaVersion: z.union([z.literal(4), z.literal(5), z.literal(6)]),
25
25
  id: z.string(),
26
26
  project: projectSchema,
27
27
  title: z.string(),
@@ -33,6 +33,12 @@ const taskSchema = z.object({
33
33
  turnId: z.string().nullable(),
34
34
  updatedAt: z.string(),
35
35
  updatedBy: z.enum(["dispatcher", "mcp"]),
36
+ results: z.array(z.object({
37
+ status: z.enum(["needs_input", "completed", "failed"]),
38
+ summary: z.string(),
39
+ turnId: z.string().nullable(),
40
+ updatedAt: z.string(),
41
+ })),
36
42
  lastResult: z.object({
37
43
  status: z.enum(["needs_input", "completed", "failed"]),
38
44
  summary: z.string(),