taskchef 7.19.1 → 7.21.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.
@@ -11,6 +11,10 @@
11
11
  --danger: #8b3a35;
12
12
  --warning: #8a5c17;
13
13
  --shadow: 0 12px 30px rgb(24 33 29 / 10%);
14
+ --control-height: 38px;
15
+ --control-padding-inline: 11px;
16
+ --control-radius: 7px;
17
+ --control-font-size: 0.86rem;
14
18
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
15
19
  }
16
20
 
@@ -112,7 +116,7 @@ time, .timestamp-missing { font-size: 0.75rem; }
112
116
  .timestamp-toggle:hover time { color: var(--text); text-decoration: underline; text-underline-offset: 3px; }
113
117
  .task-footer { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
114
118
  .task-open { flex: none; }
115
- .primary-button.task-action, .secondary-button.task-action { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 34px; padding: 5px 9px; font-size: 0.78rem; line-height: 1.2; white-space: nowrap; }
119
+ .primary-button.task-action, .secondary-button.task-action { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: var(--control-height); padding: 0 var(--control-padding-inline); font-size: var(--control-font-size); line-height: 1; white-space: nowrap; }
116
120
  .codex-icon { display: block; flex: none; width: 18px; height: 18px; }
117
121
  .codex-icon img { display: block; width: 100%; height: 100%; object-fit: contain; }
118
122
 
@@ -148,8 +152,15 @@ dialog { width: min(760px, calc(100vw - 32px)); max-height: min(82vh, 900px); pa
148
152
  dialog::backdrop { background: rgb(18 23 21 / 50%); backdrop-filter: blur(2px); }
149
153
  .dialog-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
150
154
  .dialog-header h2 { margin-bottom: 12px; font-size: 1.6rem; }
151
- .dialog-actions { display: flex; gap: 8px; margin-bottom: 26px; }
152
- .primary-button, .secondary-button, .danger-button { padding: 8px 12px; border-radius: 7px; font-weight: 700; cursor: pointer; }
155
+ .dialog-actions { display: flex; align-items: flex-start; flex-wrap: wrap; gap: 8px; margin-bottom: 26px; }
156
+ .more-task-actions { width: var(--control-height); min-width: var(--control-height); padding-inline: 0; font-size: 1.2rem; line-height: 1; }
157
+ .manual-transition-panel, .manual-transition-controls { display: contents; }
158
+ .manual-transition-panel[hidden] { display: none; }
159
+ .manual-transition-error, .manual-transition-pending { flex-basis: 100%; margin: 0; }
160
+ .manual-transition-controls > button[hidden] { display: none; }
161
+ .manual-transition-error { color: var(--danger); font-size: 0.86rem; }
162
+ .manual-transition-pending { color: var(--muted); font-size: 0.86rem; }
163
+ .primary-button, .secondary-button, .danger-button { display: inline-flex; align-items: center; justify-content: center; min-height: var(--control-height); padding: 0 var(--control-padding-inline); border-radius: var(--control-radius); font-size: var(--control-font-size); font-weight: 700; line-height: 1; cursor: pointer; }
153
164
  .primary-button { border: 1px solid var(--accent); background: var(--accent); color: white; }
154
165
  .secondary-button { border: 1px solid var(--border); background: var(--surface); }
155
166
  .danger-button { border: 1px solid var(--danger); background: var(--surface); color: var(--danger); }
@@ -160,11 +171,20 @@ dialog section + section { margin-top: 24px; }
160
171
  .result-history-item { padding: 13px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-muted); }
161
172
  .result-history-latest { border-color: var(--accent); background: var(--accent-soft); box-shadow: inset 3px 0 var(--accent); }
162
173
  .result-history-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
174
+ .result-history-header .timestamp-toggle { margin-left: auto; }
175
+ .result-history-source { color: var(--muted); font-size: 0.72rem; font-weight: 700; }
163
176
  .result-history-item p { margin-bottom: 7px; }
164
177
  .result-history-item p:last-child { margin-bottom: 0; }
165
178
  .result-history-item h4 { margin: 10px 0 3px; color: var(--muted); font-size: 0.72rem; letter-spacing: 0.04em; text-transform: uppercase; }
166
179
  .result-history-turn, .result-history-empty { color: var(--muted); font-size: 0.78rem; overflow-wrap: anywhere; }
167
180
  .result-history-empty { margin: 0; }
181
+ .usage-panel { margin-top: 8px; }
182
+ .usage-summary { display: grid; gap: 3px; margin: 10px 0 4px; color: var(--muted); font-size: 0.8rem; }
183
+ .usage-summary strong { color: var(--text); font-size: 0.9rem; }
184
+ .usage-calculating { display: flex; align-items: center; gap: 7px; }
185
+ .usage-spinner { width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: usage-spin 0.8s linear infinite; }
186
+ @keyframes usage-spin { to { transform: rotate(360deg); } }
187
+ @media (prefers-reduced-motion: reduce) { .usage-spinner { animation: none; border-top-color: var(--border); background: var(--accent); } }
168
188
  pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius: 7px; background: var(--surface-muted); white-space: pre-wrap; overflow-wrap: anywhere; font: 0.86rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
169
189
  .metadata { display: grid; grid-template-columns: minmax(100px, 150px) 1fr; margin: 0; font-size: 0.88rem; }
170
190
  .metadata dt, .metadata dd { padding: 7px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
@@ -175,6 +195,7 @@ pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius
175
195
  :focus-visible { outline: 3px solid rgb(33 95 74 / 35%); outline-offset: 3px; }
176
196
 
177
197
  @media (max-width: 650px) {
198
+ :root { --control-height: 40px; }
178
199
  .site-header { align-items: flex-start; gap: 14px; padding-top: 28px; }
179
200
  .title-row { align-items: flex-start; }
180
201
  .toolbar-primary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -189,7 +210,6 @@ pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius
189
210
  .task-open { align-self: flex-start; }
190
211
  select { min-height: 40px; }
191
212
  .status-filter-option span { min-height: 38px; }
192
- .primary-button.task-action, .secondary-button.task-action { min-height: 40px; }
193
213
  .dialog-actions { align-items: center; flex-flow: row wrap; }
194
214
  .metadata { grid-template-columns: 1fr; }
195
215
  .metadata dt { padding-bottom: 0; border-bottom: 0; }
package/src/dashboard.js CHANGED
@@ -16,12 +16,14 @@ import {
16
16
  acquireWorkspaceLock,
17
17
  canonicalDirectory,
18
18
  canonicalGitRoot,
19
+ manuallyTransitionTask,
19
20
  parseTaskLogContent,
20
21
  readConfig,
21
22
  readTask,
22
23
  } from "./workspace.js";
23
24
  import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
24
25
  import { taskGitHubProjection } from "./dashboard/github-links.js";
26
+ import { createUsageTracker } from "./usage-tracker.js";
25
27
 
26
28
  const TASKS_FILE_NAME = "tasks.jsonl";
27
29
  const STATIC_ROOT = fileURLToPath(new URL("./dashboard/", import.meta.url));
@@ -30,6 +32,7 @@ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1"]);
30
32
  const DEFAULT_MAX_FILE_BYTES = 16 * 1024 * 1024;
31
33
  const DEFAULT_MAX_TASKS = 2_000;
32
34
  const DEFAULT_MAX_EVENT_CLIENTS = 16;
35
+ const MAX_MANUAL_TRANSITION_BODY_BYTES = 4 * 1024;
33
36
  export const DASHBOARD_HEALTH_PATH = "/api/health";
34
37
  export const DASHBOARD_HEALTH_MAX_BYTES = 8 * 1024;
35
38
  const CONTENT_SECURITY_POLICY = [
@@ -159,6 +162,11 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
159
162
  boundedText(turn.turnRef, 512, `${name} turn ${turnIndex + 1} turn ref`);
160
163
  boundedText(turn.turnId, 512, `${name} turn ${turnIndex + 1} turn ID`);
161
164
  boundedText(turn.result?.summary, 2_000, `${name} turn ${turnIndex + 1} result summary`);
165
+ boundedText(
166
+ turn.provenance?.actionId,
167
+ 512,
168
+ `${name} turn ${turnIndex + 1} manual action ID`,
169
+ );
162
170
  }
163
171
  const results = task.results ?? [];
164
172
  if (results.length > 10_000) {
@@ -168,6 +176,11 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
168
176
  boundedText(result.summary, 2_000, `${name} result ${resultIndex + 1} summary`);
169
177
  boundedText(result.turnRef, 512, `${name} result ${resultIndex + 1} turn ref`);
170
178
  boundedText(result.turnId, 512, `${name} result ${resultIndex + 1} turn ID`);
179
+ boundedText(
180
+ result.provenance?.actionId,
181
+ 512,
182
+ `${name} result ${resultIndex + 1} manual action ID`,
183
+ );
171
184
  }
172
185
  boundedText(task.project.name, 1_000, `${name} project name`);
173
186
  boundedText(task.project.path, 8_192, `${name} project path`);
@@ -382,6 +395,79 @@ function sendJson(response, status, value) {
382
395
  response.end(`${JSON.stringify(value)}\n`);
383
396
  }
384
397
 
398
+ async function readBoundedJsonBody(request, maximumBytes = MAX_MANUAL_TRANSITION_BODY_BYTES) {
399
+ const contentType = request.headers["content-type"] ?? "";
400
+ if (!/^application\/json(?:\s*;|$)/i.test(contentType)) {
401
+ const error = new Error("Request content type must be application/json.");
402
+ error.code = "unsupported_media_type";
403
+ throw error;
404
+ }
405
+ const chunks = [];
406
+ let total = 0;
407
+ for await (const chunk of request) {
408
+ total += chunk.length;
409
+ if (total > maximumBytes) {
410
+ const error = new Error("Request body is too large.");
411
+ error.code = "body_too_large";
412
+ throw error;
413
+ }
414
+ chunks.push(chunk);
415
+ }
416
+ let value;
417
+ try {
418
+ value = JSON.parse(Buffer.concat(chunks, total).toString("utf8"));
419
+ } catch {
420
+ const error = new Error("Request body must be valid JSON.");
421
+ error.code = "malformed_json";
422
+ throw error;
423
+ }
424
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
425
+ const error = new Error("Request body must be a JSON object.");
426
+ error.code = "invalid_request";
427
+ throw error;
428
+ }
429
+ return value;
430
+ }
431
+
432
+ function manualTransitionInput(body) {
433
+ const fields = new Set(["schemaVersion", "actionId", "expected", "targetStatus"]);
434
+ const unexpected = Object.keys(body).find((key) => !fields.has(key));
435
+ const missing = [...fields].find((key) => !(key in body));
436
+ if (unexpected || missing || body.schemaVersion !== 1) {
437
+ const error = new Error("Manual transition request has an invalid shape.");
438
+ error.code = "invalid_request";
439
+ throw error;
440
+ }
441
+ const { schemaVersion: _schemaVersion, ...input } = body;
442
+ return input;
443
+ }
444
+
445
+ function manualTransitionErrorResponse(error) {
446
+ const definitions = new Map([
447
+ ["malformed_json", [400, "Request body must be valid JSON."]],
448
+ ["invalid_request", [400, "Manual transition request is invalid."]],
449
+ ["task_not_found", [404, "Task not found."]],
450
+ ["stale_task", [409, "This task changed. Review its current state and try again."]],
451
+ ["invalid_transition", [409, "This task can no longer be changed manually."]],
452
+ ["idempotency_conflict", [409, "This manual action ID was already used."]],
453
+ ["body_too_large", [413, "Request body is too large."]],
454
+ ["unsupported_media_type", [415, "Request content type must be application/json."]],
455
+ ["ELOCKED", [503, "TaskChef is busy updating the workspace. Try again."]],
456
+ ]);
457
+ const definition = definitions.get(error?.code);
458
+ const [status, message] = definition ?? [500, "Dashboard request failed."];
459
+ return {
460
+ status,
461
+ body: {
462
+ code: error?.code === "ELOCKED"
463
+ ? "workspace_busy"
464
+ : (definition ? error.code : "dashboard_error"),
465
+ message,
466
+ ...(error?.task ? { task: taskDetailProjection(error.task) } : {}),
467
+ },
468
+ };
469
+ }
470
+
385
471
  function ssePayload(event, value) {
386
472
  return `event: ${event}\ndata: ${JSON.stringify(value)}\n\n`;
387
473
  }
@@ -455,6 +541,7 @@ export async function createDashboardServer({
455
541
  launcher = "standalone",
456
542
  taskchefVersion = TASKCHEF_VERSION,
457
543
  serverVersion = DASHBOARD_SERVER_VERSION,
544
+ usageTracker = null,
458
545
  } = {}) {
459
546
  if (!LOOPBACK_HOSTS.has(host)) {
460
547
  throw new Error("dashboard host must be a loopback address");
@@ -470,6 +557,7 @@ export async function createDashboardServer({
470
557
  }
471
558
  const monitor = new DashboardMonitor(workspace, monitorOptions);
472
559
  await monitor.start();
560
+ const taskUsageTracker = usageTracker ?? createUsageTracker({ workspace: monitor.workspace });
473
561
  const identity = Object.freeze({
474
562
  schemaVersion: 1,
475
563
  service: "taskchef-dashboard",
@@ -574,7 +662,47 @@ export async function createDashboardServer({
574
662
  response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
575
663
  response.end();
576
664
  } else {
577
- sendJson(response, 200, { schemaVersion: 1, task: taskDetailProjection(task) });
665
+ const usage = await taskUsageTracker.get(task).catch(() => ({
666
+ status: "unavailable",
667
+ reason: "Task usage is temporarily unavailable.",
668
+ task: null,
669
+ turns: {},
670
+ }));
671
+ sendJson(response, 200, {
672
+ schemaVersion: 1,
673
+ task: { ...taskDetailProjection(task), usage },
674
+ });
675
+ }
676
+ return;
677
+ }
678
+
679
+ const transitionMatch = url.pathname.match(
680
+ /^\/api\/tasks\/([a-zA-Z0-9._-]+)\/manual-transition$/,
681
+ );
682
+ if (transitionMatch && method === "POST") {
683
+ if (request.headers.origin !== allowedOrigin) {
684
+ sendJson(response, 403, {
685
+ code: "invalid_origin",
686
+ message: "Dashboard origin validation failed.",
687
+ });
688
+ return;
689
+ }
690
+ try {
691
+ const body = await readBoundedJsonBody(request);
692
+ const result = await manuallyTransitionTask(
693
+ monitor.workspace,
694
+ transitionMatch[1],
695
+ manualTransitionInput(body),
696
+ );
697
+ await monitor.refresh({ force: true }).catch(() => {});
698
+ sendJson(response, 200, {
699
+ schemaVersion: 1,
700
+ task: taskDetailProjection(result.task),
701
+ idempotent: result.idempotent,
702
+ });
703
+ } catch (error) {
704
+ const failure = manualTransitionErrorResponse(error);
705
+ sendJson(response, failure.status, failure.body);
578
706
  }
579
707
  return;
580
708
  }
package/src/mcp.js CHANGED
@@ -13,6 +13,7 @@ import { parseTaskChefMarker } from "./delegation.js";
13
13
  import { createDashboardManager } from "./dashboard-manager.js";
14
14
  import { resolveWorkspacePath } from "./workspace-path.js";
15
15
  import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
16
+ import { createUsageTracker } from "./usage-tracker.js";
16
17
 
17
18
  const projectSchema = z.object({
18
19
  name: z.string(),
@@ -22,9 +23,23 @@ const projectSchema = z.object({
22
23
  description: z.string().optional(),
23
24
  });
24
25
 
26
+ const turnProvenanceSchema = z.union([
27
+ z.object({ kind: z.enum(["legacy", "mcp"]) }),
28
+ z.object({
29
+ kind: z.literal("dashboard_manual"),
30
+ actionId: z.string(),
31
+ fromStatus: z.enum(["working", "needs_input"]),
32
+ toStatus: z.enum(["completed", "failed"]),
33
+ expectedTurnRef: z.string().nullable(),
34
+ expectedThreadId: z.string().nullable(),
35
+ expectedUpdatedAt: z.string(),
36
+ }),
37
+ ]);
38
+
25
39
  const taskSchema = z.object({
26
40
  schemaVersion: z.union([
27
41
  z.literal(4), z.literal(5), z.literal(6), z.literal(7), z.literal(8), z.literal(9),
42
+ z.literal(10),
28
43
  ]),
29
44
  id: z.string(),
30
45
  project: projectSchema,
@@ -37,7 +52,7 @@ const taskSchema = z.object({
37
52
  turnRef: z.string().nullable(),
38
53
  turnId: z.string().nullable(),
39
54
  updatedAt: z.string(),
40
- updatedBy: z.enum(["dispatcher", "mcp"]),
55
+ updatedBy: z.enum(["dispatcher", "mcp", "dashboard"]),
41
56
  turns: z.array(z.object({
42
57
  turnRef: z.string().nullable(),
43
58
  turnId: z.string().nullable(),
@@ -48,6 +63,7 @@ const taskSchema = z.object({
48
63
  summary: z.string(),
49
64
  updatedAt: z.string(),
50
65
  }).nullable(),
66
+ provenance: turnProvenanceSchema.nullable(),
51
67
  })),
52
68
  latestTurn: z.object({
53
69
  turnRef: z.string().nullable(),
@@ -59,6 +75,7 @@ const taskSchema = z.object({
59
75
  summary: z.string(),
60
76
  updatedAt: z.string(),
61
77
  }).nullable(),
78
+ provenance: turnProvenanceSchema.nullable(),
62
79
  }).nullable(),
63
80
  results: z.array(z.object({
64
81
  status: z.enum(["needs_input", "completed", "failed"]),
@@ -66,6 +83,7 @@ const taskSchema = z.object({
66
83
  turnRef: z.string().nullable(),
67
84
  turnId: z.string().nullable(),
68
85
  updatedAt: z.string(),
86
+ provenance: turnProvenanceSchema.optional(),
69
87
  })),
70
88
  lastResult: z.object({
71
89
  status: z.enum(["needs_input", "completed", "failed"]),
@@ -73,6 +91,7 @@ const taskSchema = z.object({
73
91
  turnRef: z.string().nullable(),
74
92
  turnId: z.string().nullable(),
75
93
  updatedAt: z.string(),
94
+ provenance: turnProvenanceSchema.optional(),
76
95
  }).nullable(),
77
96
  });
78
97
 
@@ -145,6 +164,7 @@ export function createTaskChefMcpServer({
145
164
  dashboardManager = createDashboardManager({ workspace }),
146
165
  readConfiguration = readConfig,
147
166
  logDashboardDiagnostic,
167
+ usageTracker = createUsageTracker({ workspace }),
148
168
  } = {}) {
149
169
  const server = new McpServer(
150
170
  { name: "taskchef", version: TASKCHEF_VERSION },
@@ -287,6 +307,7 @@ export function createTaskChefMcpServer({
287
307
  },
288
308
  async (input) => {
289
309
  const task = await reportState(workspace, input);
310
+ void usageTracker.observe(task).catch(() => {});
290
311
  return toolResult("task", task, `Recorded ${task.status} state for TaskChef task ${task.id}.`);
291
312
  },
292
313
  );
@@ -314,6 +335,7 @@ export function createTaskChefMcpServer({
314
335
  },
315
336
  async (input) => {
316
337
  const task = await reportResult(workspace, input);
338
+ void usageTracker.observe(task).catch(() => {});
317
339
  return toolResult("task", task, `Recorded ${task.status} result for TaskChef task ${task.id}.`);
318
340
  },
319
341
  );