taskchef 7.3.0 → 7.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  initializeWorkspace,
15
15
  listProjects,
16
16
  listTasks,
17
+ migrateTaskLog,
17
18
  prepareDispatch,
18
19
  recordTask,
19
20
  removeProject,
@@ -182,11 +183,28 @@ function taskDetails(task) {
182
183
  `Updated by: ${singleLineDetail(task.updatedBy ?? "-")}`,
183
184
  `Task ID: ${singleLineDetail(task.id)}`,
184
185
  `Thread ID: ${singleLineDetail(task.threadId ?? "-")}`,
186
+ `Result count: ${task.results.length}`,
187
+ "Result history (newest first):",
188
+ ...[...task.results].reverse().map((result) => (
189
+ `- ${singleLineDetail(result.updatedAt)} | ${singleLineDetail(result.status)} | turn ${singleLineDetail(result.turnId ?? "-")} | ${singleLineDetail(result.summary)}`
190
+ )),
185
191
  "Instruction:",
186
192
  task.instruction,
187
193
  ].join("\n");
188
194
  }
189
195
 
196
+ async function migrate(args) {
197
+ validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
198
+ const result = await migrateTaskLog(workspaceRoot(args));
199
+ print(result, args, (value) => [
200
+ `Task log: ${value.action}`,
201
+ `Tasks: ${value.taskCount}`,
202
+ `Migrated: ${value.migratedCount}`,
203
+ `Backup: ${value.backupPath ?? "not needed"}`,
204
+ ].join("\n"));
205
+ return 0;
206
+ }
207
+
190
208
  async function readTaskForShow(workspace, taskId) {
191
209
  const id = requireSafeId(taskId, "taskId");
192
210
  const tasks = await listTasks(workspace);
@@ -409,10 +427,22 @@ async function dashboard(args) {
409
427
  values: ["--port", "--workspace"],
410
428
  switches: ["--json"],
411
429
  });
412
- const server = await createDashboardServer({
413
- workspace: workspaceRoot(args),
414
- port: dashboardPort(args),
415
- });
430
+ const port = dashboardPort(args);
431
+ let server;
432
+ try {
433
+ server = await createDashboardServer({
434
+ workspace: workspaceRoot(args),
435
+ port,
436
+ });
437
+ } catch (error) {
438
+ if (error?.code === "EADDRINUSE") {
439
+ throw new Error(
440
+ `dashboard port 127.0.0.1:${port} is already in use; `
441
+ + "stop the existing listener or choose another --port (TaskChef will not terminate it)",
442
+ );
443
+ }
444
+ throw error;
445
+ }
416
446
  print({
417
447
  schemaVersion: 1,
418
448
  url: server.url,
@@ -444,6 +474,7 @@ Usage:
444
474
  taskchef doctor [--json] [--workspace <path>]
445
475
  taskchef workspace path [--json] [--workspace <path>]
446
476
  taskchef workspace init [--register-codex] [--codex-cli <path>] [--json] [--workspace <path>]
477
+ taskchef workspace migrate [--json] [--workspace <path>]
447
478
  taskchef project add <path> [--name <name>] [--description <text>] [--github-repo <url> ... | --no-github] [--json] [--workspace <path>]
448
479
  taskchef project import [<file> | -] [--replace] [--json] [--workspace <path>]
449
480
  taskchef project list [--json] [--workspace <path>]
@@ -461,8 +492,10 @@ Project import reads a JSON
461
492
  array from a file, or from standard input when the source is '-' or omitted.
462
493
  Workspace resolution precedence is --workspace, TASKCHEF_WORKSPACE, then
463
494
  ~/.agents/taskchef.
464
- The dashboard binds to 127.0.0.1 and reads the canonical task log without
465
- modifying dispatcher-workspace files.
495
+ The foreground dashboard binds to 127.0.0.1 and reads the canonical task log
496
+ without modifying dispatcher-workspace files. The dispatcher MCP may reuse a
497
+ compatible foreground server on port 3210; neither mode terminates a listener
498
+ that already occupies its requested port.
466
499
  `);
467
500
  }
468
501
 
@@ -475,6 +508,7 @@ export async function runCli(args) {
475
508
  if (args[0] === "doctor") return doctor(args);
476
509
  if (args[0] === "workspace" && args[1] === "path") return workspacePath(args);
477
510
  if (args[0] === "workspace" && args[1] === "init") return initialize(args);
511
+ if (args[0] === "workspace" && args[1] === "migrate") return migrate(args);
478
512
  if (args[0] === "project" && args[1] === "add") return projectAdd(args);
479
513
  if (args[0] === "project" && args[1] === "import") return projectImport(args);
480
514
  if (args[0] === "project" && args[1] === "list") return projectList(args);
@@ -17,6 +17,7 @@ const state = {
17
17
  selectedTask: null,
18
18
  };
19
19
  let dateRefreshTimer = null;
20
+ let detailRequestGeneration = 0;
20
21
 
21
22
  const elements = {
22
23
  clearNotifications: document.querySelector("#clear-notifications"),
@@ -31,7 +32,7 @@ const elements = {
31
32
  dialogInstruction: document.querySelector("#dialog-instruction"),
32
33
  dialogMetadata: document.querySelector("#dialog-metadata"),
33
34
  dialogProject: document.querySelector("#dialog-project"),
34
- dialogSummary: document.querySelector("#dialog-summary"),
35
+ dialogResults: document.querySelector("#dialog-results"),
35
36
  dialogTitle: document.querySelector("#dialog-title"),
36
37
  dismissDashboardMessage: document.querySelector("#dismiss-dashboard-message"),
37
38
  emptyState: document.querySelector("#empty-state"),
@@ -119,12 +120,48 @@ function detailRow(term, value) {
119
120
  return [dt, dd];
120
121
  }
121
122
 
122
- function openDialog(task) {
123
- state.selectedTask = task;
123
+ function resultHistory(results) {
124
+ if (results.length === 0) {
125
+ const empty = document.createElement("p");
126
+ empty.className = "result-history-empty";
127
+ empty.textContent = "No semantic result has been reported yet.";
128
+ return [empty];
129
+ }
130
+ return [...results].reverse().map((result, index) => {
131
+ const item = document.createElement("article");
132
+ item.className = `result-history-item${index === 0 ? " result-history-latest" : ""}`;
133
+ const header = document.createElement("div");
134
+ header.className = "result-history-header";
135
+ const status = document.createElement("span");
136
+ status.className = `status status-${result.status}`;
137
+ status.textContent = result.status.replaceAll("_", " ");
138
+ const timestamp = document.createElement("time");
139
+ timestamp.dateTime = result.updatedAt;
140
+ timestamp.textContent = formatTime(result.updatedAt);
141
+ header.append(status, timestamp);
142
+ const summary = document.createElement("p");
143
+ summary.className = "preserve-lines";
144
+ summary.textContent = result.summary;
145
+ const turn = document.createElement("p");
146
+ turn.className = "result-history-turn";
147
+ turn.textContent = result.turnId ? `Turn ${result.turnId}` : "No turn ID (creation failure)";
148
+ item.append(header, summary, turn);
149
+ return item;
150
+ });
151
+ }
152
+
153
+ function renderDialog(task) {
154
+ const preservedResults = state.selectedTask?.id === task.id
155
+ ? state.selectedTask.results
156
+ : null;
157
+ const detailedTask = {
158
+ ...task,
159
+ results: task.results ?? preservedResults ?? [],
160
+ };
161
+ state.selectedTask = detailedTask;
124
162
  elements.dialogProject.textContent = task.project.name;
125
163
  elements.dialogTitle.textContent = task.title;
126
- elements.dialogSummary.textContent = task.lastResult?.summary
127
- ?? "No semantic result has been reported yet.";
164
+ elements.dialogResults.replaceChildren(...resultHistory(detailedTask.results));
128
165
  elements.dialogInstruction.textContent = task.instruction;
129
166
  elements.copyThreadId.disabled = !task.threadId;
130
167
  elements.dialogMetadata.replaceChildren(
@@ -142,7 +179,28 @@ function openDialog(task) {
142
179
  )),
143
180
  ...detailRow("Updated by", task.updatedBy),
144
181
  );
182
+ }
183
+
184
+ async function openDialog(task) {
185
+ const requestGeneration = ++detailRequestGeneration;
186
+ renderDialog(task);
145
187
  if (!elements.dialog.open) elements.dialog.showModal();
188
+ try {
189
+ const response = await fetch(`/api/tasks/${encodeURIComponent(task.id)}`);
190
+ if (!response.ok) throw new Error("Task details are unavailable.");
191
+ const detail = await response.json();
192
+ if (
193
+ requestGeneration === detailRequestGeneration
194
+ && state.selectedTask?.id === task.id
195
+ && elements.dialog.open
196
+ ) {
197
+ renderDialog(detail.task);
198
+ }
199
+ } catch {
200
+ if (state.selectedTask?.id === task.id) {
201
+ showMessage("Task result history is temporarily unavailable.");
202
+ }
203
+ }
146
204
  }
147
205
 
148
206
  function taskCard(task) {
@@ -87,8 +87,8 @@
87
87
  <button id="copy-thread-id" class="secondary-button" type="button">Copy thread ID</button>
88
88
  </div>
89
89
  <section>
90
- <h3>Result</h3>
91
- <p id="dialog-summary" class="preserve-lines"></p>
90
+ <h3>Result history</h3>
91
+ <div id="dialog-results" class="result-history"></div>
92
92
  </section>
93
93
  <section>
94
94
  <h3>Original instruction</h3>
@@ -117,6 +117,14 @@ dialog::backdrop { background: rgb(18 23 21 / 50%); backdrop-filter: blur(2px);
117
117
  .primary-button:disabled, .secondary-button:disabled { opacity: 0.5; cursor: not-allowed; }
118
118
  dialog section + section { margin-top: 24px; }
119
119
  .preserve-lines { white-space: pre-wrap; }
120
+ .result-history { display: grid; gap: 10px; }
121
+ .result-history-item { padding: 13px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-muted); }
122
+ .result-history-latest { border-color: var(--accent); background: var(--accent-soft); box-shadow: inset 3px 0 var(--accent); }
123
+ .result-history-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
124
+ .result-history-item p { margin-bottom: 7px; }
125
+ .result-history-item p:last-child { margin-bottom: 0; }
126
+ .result-history-turn, .result-history-empty { color: var(--muted); font-size: 0.78rem; overflow-wrap: anywhere; }
127
+ .result-history-empty { margin: 0; }
120
128
  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
129
  .metadata { display: grid; grid-template-columns: minmax(100px, 150px) 1fr; margin: 0; font-size: 0.88rem; }
122
130
  .metadata dt, .metadata dd { padding: 7px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
@@ -0,0 +1,183 @@
1
+ import http from "node:http";
2
+ import { realpath } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import {
6
+ DASHBOARD_HEALTH_MAX_BYTES,
7
+ DASHBOARD_HEALTH_PATH,
8
+ createDashboardServer,
9
+ dashboardAuthority,
10
+ } from "./dashboard.js";
11
+ import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
12
+
13
+ const DEFAULT_HOST = "127.0.0.1";
14
+ const DEFAULT_PORT = 3210;
15
+ const HEALTH_TIMEOUT_MS = 750;
16
+
17
+ function expectedIdentity(workspace, taskchefVersion, serverVersion) {
18
+ return {
19
+ schemaVersion: 1,
20
+ service: "taskchef-dashboard",
21
+ taskchefVersion,
22
+ serverVersion,
23
+ workspace,
24
+ };
25
+ }
26
+
27
+ function isExactIdentity(value, expected) {
28
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
29
+ const keys = Object.keys(value).sort();
30
+ const expectedKeys = Object.keys(expected).sort();
31
+ return keys.length === expectedKeys.length
32
+ && keys.every((key, index) => key === expectedKeys[index])
33
+ && expectedKeys.every((key) => value[key] === expected[key]);
34
+ }
35
+
36
+ function listenerConflict(url, detail) {
37
+ return new Error(
38
+ `TaskChef dashboard port conflict at ${url} ${detail} `
39
+ + "Stop that listener or choose another port for the foreground dashboard CLI; TaskChef will not terminate it.",
40
+ );
41
+ }
42
+
43
+ export function readDashboardIdentity({
44
+ host = DEFAULT_HOST,
45
+ port = DEFAULT_PORT,
46
+ maximumBytes = DASHBOARD_HEALTH_MAX_BYTES,
47
+ timeoutMs = HEALTH_TIMEOUT_MS,
48
+ } = {}) {
49
+ return new Promise((resolve, reject) => {
50
+ let settled = false;
51
+ const finish = (error, value) => {
52
+ if (settled) return;
53
+ settled = true;
54
+ clearTimeout(deadline);
55
+ if (error) reject(error);
56
+ else resolve(value);
57
+ };
58
+ const request = http.get({
59
+ host,
60
+ port,
61
+ path: DASHBOARD_HEALTH_PATH,
62
+ headers: {
63
+ Accept: "application/json",
64
+ Host: dashboardAuthority(host, port),
65
+ },
66
+ }, (response) => {
67
+ const chunks = [];
68
+ let total = 0;
69
+ response.on("data", (chunk) => {
70
+ total += chunk.length;
71
+ if (total > maximumBytes) {
72
+ const error = new Error("dashboard health response exceeds the identity limit");
73
+ finish(error);
74
+ request.destroy(error);
75
+ return;
76
+ }
77
+ chunks.push(chunk);
78
+ });
79
+ response.on("end", () => {
80
+ if (response.statusCode !== 200) {
81
+ finish(new Error(`dashboard health returned HTTP ${response.statusCode}`));
82
+ return;
83
+ }
84
+ try {
85
+ finish(null, JSON.parse(Buffer.concat(chunks, total).toString("utf8")));
86
+ } catch {
87
+ finish(new Error("dashboard health returned invalid JSON"));
88
+ }
89
+ });
90
+ });
91
+ const deadline = setTimeout(() => {
92
+ const error = new Error("dashboard health request timed out");
93
+ finish(error);
94
+ request.destroy(error);
95
+ }, timeoutMs);
96
+ request.on("error", (error) => finish(error));
97
+ });
98
+ }
99
+
100
+ function listenerAbsent(error) {
101
+ return error?.code === "ECONNREFUSED" || error?.code === "EHOSTUNREACH";
102
+ }
103
+
104
+ export function createDashboardManager({
105
+ workspace,
106
+ host = DEFAULT_HOST,
107
+ port = DEFAULT_PORT,
108
+ taskchefVersion = TASKCHEF_VERSION,
109
+ serverVersion = DASHBOARD_SERVER_VERSION,
110
+ createServer = createDashboardServer,
111
+ readIdentity = readDashboardIdentity,
112
+ } = {}) {
113
+ let canonicalWorkspace;
114
+ let ownedServer = null;
115
+ let ensurePromise = null;
116
+ let closePromise = null;
117
+
118
+ const publicResult = (action) => ({
119
+ action,
120
+ url: `http://${dashboardAuthority(host, ownedServer?.port ?? port)}/`,
121
+ workspace: canonicalWorkspace,
122
+ taskchefVersion,
123
+ serverVersion,
124
+ });
125
+
126
+ const probe = async () => {
127
+ const url = `http://${dashboardAuthority(host, port)}/`;
128
+ let identity;
129
+ try {
130
+ identity = await readIdentity({ host, port });
131
+ } catch (error) {
132
+ if (listenerAbsent(error)) return false;
133
+ throw listenerConflict(url, `is occupied but did not return a compatible identity (${error.message}).`);
134
+ }
135
+ const expected = expectedIdentity(canonicalWorkspace, taskchefVersion, serverVersion);
136
+ if (!isExactIdentity(identity, expected)) {
137
+ throw listenerConflict(url, "belongs to an unknown, stale, or different-workspace service.");
138
+ }
139
+ return true;
140
+ };
141
+
142
+ const ensureOnce = async () => {
143
+ canonicalWorkspace ??= await realpath(path.resolve(workspace));
144
+ if (ownedServer) return publicResult("reused");
145
+ if (await probe()) return publicResult("reused");
146
+ try {
147
+ ownedServer = await createServer({
148
+ workspace: canonicalWorkspace,
149
+ host,
150
+ port,
151
+ taskchefVersion,
152
+ serverVersion,
153
+ });
154
+ return publicResult("started");
155
+ } catch (error) {
156
+ if (error?.code !== "EADDRINUSE") throw error;
157
+ if (await probe()) return publicResult("reused");
158
+ throw error;
159
+ }
160
+ };
161
+
162
+ return {
163
+ async ensure() {
164
+ if (closePromise) throw new Error("TaskChef dashboard manager is shutting down");
165
+ if (ensurePromise) {
166
+ await ensurePromise;
167
+ return publicResult("reused");
168
+ }
169
+ ensurePromise = ensureOnce().finally(() => { ensurePromise = null; });
170
+ return ensurePromise;
171
+ },
172
+ async close() {
173
+ closePromise ??= (async () => {
174
+ await ensurePromise?.catch(() => {});
175
+ const server = ownedServer;
176
+ ownedServer = null;
177
+ await server?.close();
178
+ })();
179
+ return closePromise;
180
+ },
181
+ get owned() { return ownedServer !== null; },
182
+ };
183
+ }
package/src/dashboard.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  parseTaskLogContent,
17
17
  readConfig,
18
18
  } from "./workspace.js";
19
+ import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
19
20
 
20
21
  const TASKS_FILE_NAME = "tasks.jsonl";
21
22
  const STATIC_ROOT = fileURLToPath(new URL("./dashboard/", import.meta.url));
@@ -24,6 +25,8 @@ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1"]);
24
25
  const DEFAULT_MAX_FILE_BYTES = 16 * 1024 * 1024;
25
26
  const DEFAULT_MAX_TASKS = 2_000;
26
27
  const DEFAULT_MAX_EVENT_CLIENTS = 16;
28
+ export const DASHBOARD_HEALTH_PATH = "/api/health";
29
+ export const DASHBOARD_HEALTH_MAX_BYTES = 8 * 1024;
27
30
  const CONTENT_SECURITY_POLICY = [
28
31
  "default-src 'self'",
29
32
  "base-uri 'none'",
@@ -133,6 +136,14 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
133
136
  boundedText(task.turnId, 512, `${name} turn ID`);
134
137
  boundedText(task.lastResult?.summary, 2_000, `${name} last result summary`);
135
138
  boundedText(task.lastResult?.turnId, 512, `${name} last result turn ID`);
139
+ const results = task.results ?? [];
140
+ if (results.length > 10_000) {
141
+ throw new Error(`${name} has too many results for the dashboard`);
142
+ }
143
+ for (const [resultIndex, result] of results.entries()) {
144
+ boundedText(result.summary, 2_000, `${name} result ${resultIndex + 1} summary`);
145
+ boundedText(result.turnId, 512, `${name} result ${resultIndex + 1} turn ID`);
146
+ }
136
147
  boundedText(task.project.name, 1_000, `${name} project name`);
137
148
  boundedText(task.project.path, 8_192, `${name} project path`);
138
149
  boundedText(task.project.description, 4_000, `${name} project description`);
@@ -145,6 +156,11 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
145
156
  }
146
157
  }
147
158
 
159
+ function taskListProjection(task) {
160
+ const { results: _results, ...projection } = task;
161
+ return projection;
162
+ }
163
+
148
164
  export class DashboardMonitor extends EventEmitter {
149
165
  constructor(workspace, {
150
166
  debounceMs = 75,
@@ -194,7 +210,7 @@ export class DashboardMonitor extends EventEmitter {
194
210
  generatedAt: new Date().toISOString(),
195
211
  healthy: !this.unhealthy,
196
212
  tasks: this.tasks.map((task) => ({
197
- ...task,
213
+ ...taskListProjection(task),
198
214
  meaningfulUpdatedAt: new Date(
199
215
  meaningfulUpdateTime(task, this.observedUpdateTimes),
200
216
  ).toISOString(),
@@ -402,6 +418,8 @@ export async function createDashboardServer({
402
418
  monitorOptions = {},
403
419
  openProject = null,
404
420
  openThread = null,
421
+ taskchefVersion = TASKCHEF_VERSION,
422
+ serverVersion = DASHBOARD_SERVER_VERSION,
405
423
  } = {}) {
406
424
  if (!LOOPBACK_HOSTS.has(host)) {
407
425
  throw new Error("dashboard host must be a loopback address");
@@ -414,6 +432,17 @@ export async function createDashboardServer({
414
432
  }
415
433
  const monitor = new DashboardMonitor(workspace, monitorOptions);
416
434
  await monitor.start();
435
+ const identity = Object.freeze({
436
+ schemaVersion: 1,
437
+ service: "taskchef-dashboard",
438
+ taskchefVersion,
439
+ serverVersion,
440
+ workspace: monitor.workspace,
441
+ });
442
+ if (Buffer.byteLength(`${JSON.stringify(identity)}\n`) > DASHBOARD_HEALTH_MAX_BYTES) {
443
+ monitor.close();
444
+ throw new Error("dashboard identity exceeds the health response limit");
445
+ }
417
446
  const clients = new Set();
418
447
  let allowedAuthority;
419
448
  let allowedOrigin;
@@ -446,6 +475,16 @@ export async function createDashboardServer({
446
475
  return;
447
476
  }
448
477
 
478
+ if (url.pathname === DASHBOARD_HEALTH_PATH && (method === "GET" || method === "HEAD")) {
479
+ if (method === "HEAD") {
480
+ response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
481
+ response.end();
482
+ } else {
483
+ sendJson(response, 200, identity);
484
+ }
485
+ return;
486
+ }
487
+
449
488
  if (url.pathname === "/api/snapshot" && (method === "GET" || method === "HEAD")) {
450
489
  if (method === "HEAD") {
451
490
  response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
@@ -484,6 +523,22 @@ export async function createDashboardServer({
484
523
  return;
485
524
  }
486
525
 
526
+ const detailMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)$/);
527
+ if (detailMatch && (method === "GET" || method === "HEAD")) {
528
+ const task = monitor.tasks.find((candidate) => candidate.id === detailMatch[1]);
529
+ if (!task) {
530
+ sendJson(response, 404, { message: "Task not found." });
531
+ return;
532
+ }
533
+ if (method === "HEAD") {
534
+ response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
535
+ response.end();
536
+ } else {
537
+ sendJson(response, 200, { schemaVersion: 1, task });
538
+ }
539
+ return;
540
+ }
541
+
487
542
  const taskMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)\/open-codex$/);
488
543
  if (taskMatch && method === "POST") {
489
544
  if (request.headers.origin !== allowedOrigin) {
@@ -574,6 +629,7 @@ export async function createDashboardServer({
574
629
  port: boundPort,
575
630
  origin: allowedOrigin,
576
631
  url: `${allowedOrigin}/`,
632
+ identity,
577
633
  monitor,
578
634
  get eventClientCount() { return clients.size; },
579
635
  async close() {
package/src/mcp.js CHANGED
@@ -8,7 +8,9 @@ import {
8
8
  reportTaskResult,
9
9
  } from "./workspace.js";
10
10
  import { parseTaskChefMarker } from "./delegation.js";
11
+ import { createDashboardManager } from "./dashboard-manager.js";
11
12
  import { resolveWorkspacePath } from "./workspace-path.js";
13
+ import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
12
14
 
13
15
  const projectSchema = z.object({
14
16
  name: z.string(),
@@ -19,7 +21,7 @@ const projectSchema = z.object({
19
21
  });
20
22
 
21
23
  const taskSchema = z.object({
22
- schemaVersion: z.union([z.literal(4), z.literal(5)]),
24
+ schemaVersion: z.union([z.literal(4), z.literal(5), z.literal(6)]),
23
25
  id: z.string(),
24
26
  project: projectSchema,
25
27
  title: z.string(),
@@ -31,6 +33,12 @@ const taskSchema = z.object({
31
33
  turnId: z.string().nullable(),
32
34
  updatedAt: z.string(),
33
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
+ })),
34
42
  lastResult: z.object({
35
43
  status: z.enum(["needs_input", "completed", "failed"]),
36
44
  summary: z.string(),
@@ -49,6 +57,14 @@ const preparationSchema = z.object({
49
57
  projects: z.array(projectSchema),
50
58
  });
51
59
 
60
+ const dashboardSchema = z.object({
61
+ action: z.enum(["started", "reused"]),
62
+ url: z.string().url(),
63
+ workspace: z.string(),
64
+ taskchefVersion: z.string(),
65
+ serverVersion: z.string(),
66
+ });
67
+
52
68
  function toolResult(key, value, message) {
53
69
  return {
54
70
  structuredContent: { [key]: value },
@@ -63,15 +79,53 @@ export function createTaskChefMcpServer({
63
79
  reportResult = reportTaskResult,
64
80
  reportState = reportTaskState,
65
81
  link = linkTask,
82
+ dashboardManager = createDashboardManager({ workspace }),
66
83
  } = {}) {
67
84
  const server = new McpServer(
68
- { name: "taskchef", version: "1.0.0" },
85
+ { name: "taskchef", version: TASKCHEF_VERSION },
69
86
  {
70
87
  instructions:
71
88
  "Prepare with prepare_dispatch, call record_task before creating the Codex task, then create it natively and return immediately. Follow the active TaskChef skill for role-specific sequencing of the identity and state tools.",
72
89
  },
73
90
  );
74
91
 
92
+ const originalClose = server.close.bind(server);
93
+ let closePromise = null;
94
+ server.close = async () => {
95
+ closePromise ??= (async () => {
96
+ await dashboardManager.close();
97
+ await originalClose();
98
+ })();
99
+ return closePromise;
100
+ };
101
+ server.server.onclose = () => {
102
+ void dashboardManager.close();
103
+ };
104
+
105
+ server.registerTool(
106
+ "ensure_dashboard",
107
+ {
108
+ title: "Ensure TaskChef dashboard",
109
+ description:
110
+ "Best-effort ensure the canonical TaskChef dashboard is available on 127.0.0.1:3210. Starts one dashboard inside this MCP process or reuses only an exact compatible TaskChef dashboard for the same canonical workspace; unknown listeners are never terminated or replaced.",
111
+ inputSchema: {},
112
+ outputSchema: { dashboard: dashboardSchema },
113
+ annotations: {
114
+ readOnlyHint: false,
115
+ destructiveHint: false,
116
+ openWorldHint: false,
117
+ },
118
+ },
119
+ async () => {
120
+ const dashboard = await dashboardManager.ensure();
121
+ return toolResult(
122
+ "dashboard",
123
+ dashboard,
124
+ `${dashboard.action === "started" ? "Started" : "Reused"} TaskChef dashboard ${dashboard.url}`,
125
+ );
126
+ },
127
+ );
128
+
75
129
  server.registerTool(
76
130
  "prepare_dispatch",
77
131
  {
package/src/version.js ADDED
@@ -0,0 +1,7 @@
1
+ import { createRequire } from "node:module";
2
+
3
+ const require = createRequire(import.meta.url);
4
+ const packageMetadata = require("../package.json");
5
+
6
+ export const TASKCHEF_VERSION = packageMetadata.version;
7
+ export const DASHBOARD_SERVER_VERSION = "1";