taskchef 5.9.0 → 5.10.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.9.0",
3
+ "version": "5.10.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
@@ -181,11 +181,12 @@ terminal to stop the server.
181
181
  The dashboard:
182
182
 
183
183
  - orders tasks by their latest semantic or identity update;
184
- - filters by project and status;
184
+ - filters by project, status, and latest update window (24 hours, 7 days, or
185
+ all time);
185
186
  - shows dismissible notifications when tasks are added or changed;
186
187
  - 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.
188
+ - opens the recorded task directly in Codex when it has a supported UUID thread
189
+ ID, with a project-opening fallback for unresolved or legacy identities.
189
190
 
190
191
  The server binds only to the numeric IPv4 loopback interface, requires the
191
192
  unguessable launch capability before serving instructions or results, validates
@@ -200,14 +201,26 @@ the server or browser, the dashboard rejects logs above 16 MiB, histories above
200
201
  2,000 tasks, and unusually large display fields while retaining the last valid
201
202
  snapshot. The data CLI remains unaffected by these display limits.
202
203
 
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.
204
+ Date windows advance while the page is open, even when the task file is idle.
205
+ They use persisted semantic/identity timestamps and identity changes observed by
206
+ the current server. Legacy schema-v1 identity resolutions did not record an
207
+ update timestamp, so after a dashboard restart those rare historical records
208
+ fall back to their creation time.
209
+
210
+ The dashboard uses Codex's registered `codex://threads/<thread-id>` desktop
211
+ route for direct navigation. A task-level refresh would require native Codex
212
+ metadata tools that are available to the report skill, not to a standalone
213
+ browser page. TaskChef also does not submit replies from the dashboard: `codex
214
+ resume <session> [prompt]` starts an interactive CLI session and may execute the
215
+ prompt immediately, rather than opening a reviewed draft in the desktop app.
216
+ Refresh and reply integrations remain deferred until Codex exposes supported
217
+ browser-facing contracts for those actions.
218
+
219
+ TaskChef task records do not contain model token usage, and the supported Codex
220
+ task metadata surface does not expose it to this local server. The dashboard
221
+ therefore does not estimate tokens or inspect private Codex session logs. Token
222
+ usage can be added later if Codex exposes a supported per-task usage field or
223
+ TaskChef executors begin reporting a structured usage value.
211
224
 
212
225
  ### Manage configured projects
213
226
 
package/index.js CHANGED
@@ -56,6 +56,8 @@ export {
56
56
 
57
57
  export {
58
58
  discoverCodexCli,
59
+ isCodexThreadDeepLinkId,
60
+ openThreadInCodex,
59
61
  openWorkspaceInCodex,
60
62
  } from "./src/codex-app.js";
61
63
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "5.9.0",
3
+ "version": "5.10.0",
4
4
  "description": "A non-blocking interactive dispatcher for visible Codex tasks.",
5
5
  "license": "MIT",
6
6
  "author": "Favo Yang",
package/src/codex-app.js CHANGED
@@ -5,6 +5,7 @@ import { promisify } from "node:util";
5
5
 
6
6
  const execFile = promisify(execFileCallback);
7
7
  const CODEX_COMMAND_TIMEOUT_MS = 10_000;
8
+ const CODEX_THREAD_ID_PATTERN = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
8
9
 
9
10
  function runCodex(run, filePath, args) {
10
11
  return run(filePath, args, { timeout: CODEX_COMMAND_TIMEOUT_MS, killSignal: "SIGKILL" });
@@ -26,6 +27,10 @@ function pathCandidates(env) {
26
27
  .map((directory) => path.join(directory, process.platform === "win32" ? "codex.exe" : "codex"));
27
28
  }
28
29
 
30
+ function isDesktopBundleCandidate(filePath) {
31
+ return filePath.includes(`${path.sep}Contents${path.sep}Resources${path.sep}`);
32
+ }
33
+
29
34
  async function supportsAppCommand(filePath, run) {
30
35
  try {
31
36
  const { stdout, stderr } = await runCodex(run, filePath, ["app", "--help"]);
@@ -50,8 +55,16 @@ export async function discoverCodexCli({
50
55
  return { path: candidate, source: explicit !== null ? "explicit" : "environment" };
51
56
  }
52
57
 
58
+ const candidates = pathCandidates(env);
59
+ for (const pathCandidate of candidates.filter(isDesktopBundleCandidate)) {
60
+ const candidate = await executable(pathCandidate);
61
+ if (candidate && await supportsAppCommand(candidate, run)) {
62
+ return { path: candidate, source: "desktop-path" };
63
+ }
64
+ }
65
+
53
66
  let candidate = null;
54
- for (const pathCandidate of pathCandidates(env)) {
67
+ for (const pathCandidate of candidates) {
55
68
  candidate = await executable(pathCandidate);
56
69
  if (candidate) break;
57
70
  }
@@ -59,8 +72,7 @@ export async function discoverCodexCli({
59
72
  if (!(await supportsAppCommand(candidate, run))) {
60
73
  throw new Error(`Codex CLI does not support the app command: ${candidate}`);
61
74
  }
62
- const bundled = candidate.includes(`${path.sep}Contents${path.sep}Resources${path.sep}`);
63
- return { path: candidate, source: bundled ? "desktop-path" : "path" };
75
+ return { path: candidate, source: "path" };
64
76
  }
65
77
 
66
78
  export async function openWorkspaceInCodex(workspace, options = {}) {
@@ -75,3 +87,24 @@ export async function openWorkspaceInCodex(workspace, options = {}) {
75
87
  workspace,
76
88
  };
77
89
  }
90
+
91
+ export function isCodexThreadDeepLinkId(threadId) {
92
+ return typeof threadId === "string" && CODEX_THREAD_ID_PATTERN.test(threadId);
93
+ }
94
+
95
+ export async function openThreadInCodex(threadId, options = {}) {
96
+ if (!isCodexThreadDeepLinkId(threadId)) {
97
+ throw new Error("Codex thread ID is not supported by the desktop deep link");
98
+ }
99
+ const run = options.run ?? execFile;
100
+ const platform = options.platform ?? process.platform;
101
+ const url = `codex://threads/${encodeURIComponent(threadId)}`;
102
+ if (platform === "darwin") {
103
+ await runCodex(run, "/usr/bin/open", [url]);
104
+ } else if (platform === "win32") {
105
+ await runCodex(run, "rundll32.exe", ["url.dll,FileProtocolHandler", url]);
106
+ } else {
107
+ await runCodex(run, "xdg-open", [url]);
108
+ }
109
+ return { status: "requested", mechanism: "codex-deep-link", threadId, url };
110
+ }
@@ -1,6 +1,10 @@
1
1
  import {
2
2
  findCurrentTask,
3
+ KNOWN_TASK_STATUSES,
4
+ nextDateFilterRefreshDelay,
3
5
  reconcileNotifications,
6
+ taskStatusLabel,
7
+ taskWithinDateFilter,
4
8
  } from "./state.js";
5
9
 
6
10
  const state = {
@@ -10,6 +14,7 @@ const state = {
10
14
  initialized: false,
11
15
  selectedTask: null,
12
16
  };
17
+ let dateRefreshTimer = null;
13
18
 
14
19
  const elements = {
15
20
  clearNotifications: document.querySelector("#clear-notifications"),
@@ -18,6 +23,7 @@ const elements = {
18
23
  connectionLabel: document.querySelector("#connection-label"),
19
24
  copyThreadId: document.querySelector("#copy-thread-id"),
20
25
  dashboardMessage: document.querySelector("#dashboard-message"),
26
+ dateFilter: document.querySelector("#date-filter"),
21
27
  dialog: document.querySelector("#task-dialog"),
22
28
  dialogInstruction: document.querySelector("#dialog-instruction"),
23
29
  dialogMetadata: document.querySelector("#dialog-metadata"),
@@ -26,7 +32,7 @@ const elements = {
26
32
  dialogTitle: document.querySelector("#dialog-title"),
27
33
  emptyState: document.querySelector("#empty-state"),
28
34
  notifications: document.querySelector("#notifications"),
29
- openProject: document.querySelector("#open-project"),
35
+ openProject: document.querySelector("#open-codex"),
30
36
  projectFilter: document.querySelector("#project-filter"),
31
37
  statusFilter: document.querySelector("#status-filter"),
32
38
  taskCount: document.querySelector("#task-count"),
@@ -34,10 +40,6 @@ const elements = {
34
40
  toastList: document.querySelector("#toast-list"),
35
41
  };
36
42
 
37
- function statusLabel(task) {
38
- return task.status === null ? "unresolved" : task.status.replaceAll("_", " ");
39
- }
40
-
41
43
  function formatTime(value) {
42
44
  if (!value) return "—";
43
45
  return new Intl.DateTimeFormat(undefined, {
@@ -78,7 +80,7 @@ function notificationToast(notification) {
78
80
  const title = document.createElement("strong");
79
81
  title.textContent = notification.kind === "new" ? "New task" : "Task updated";
80
82
  const description = document.createElement("span");
81
- description.textContent = `${task.title} · ${statusLabel(task)}`;
83
+ description.textContent = `${task.title} · ${taskStatusLabel(task)}`;
82
84
  text.append(title, description);
83
85
  text.addEventListener("click", () => {
84
86
  const current = findCurrentTask(state.tasks, notification.taskId);
@@ -121,13 +123,15 @@ function openDialog(task) {
121
123
  elements.dialogInstruction.textContent = task.instruction;
122
124
  elements.copyThreadId.disabled = !task.threadId;
123
125
  elements.dialogMetadata.replaceChildren(
124
- ...detailRow("Status", statusLabel(task)),
126
+ ...detailRow("Status", taskStatusLabel(task)),
125
127
  ...detailRow("Task ID", task.id),
126
128
  ...detailRow("Thread ID", task.threadId),
127
129
  ...detailRow("Turn ID", task.turnId),
128
130
  ...detailRow("Project path", task.project.path),
129
131
  ...detailRow("Created", formatTime(task.createdAt)),
130
- ...detailRow("Updated", formatTime(task.updatedAt ?? task.createdAt)),
132
+ ...detailRow("Updated", formatTime(
133
+ task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt,
134
+ )),
131
135
  ...detailRow("Updated by", task.updatedBy),
132
136
  );
133
137
  if (!elements.dialog.open) elements.dialog.showModal();
@@ -145,7 +149,7 @@ function taskCard(task) {
145
149
  title.addEventListener("click", () => openDialog(task));
146
150
  const badge = document.createElement("span");
147
151
  badge.className = `status status-${task.status ?? "unresolved"}`;
148
- badge.textContent = statusLabel(task);
152
+ badge.textContent = taskStatusLabel(task);
149
153
  heading.append(title, badge);
150
154
  const project = document.createElement("p");
151
155
  project.className = "task-project";
@@ -154,8 +158,8 @@ function taskCard(task) {
154
158
  summary.className = "task-summary";
155
159
  summary.textContent = task.summary ?? "No semantic result reported yet.";
156
160
  const time = document.createElement("time");
157
- time.dateTime = task.updatedAt ?? task.createdAt;
158
- time.textContent = `Updated ${formatTime(task.updatedAt ?? task.createdAt)}`;
161
+ time.dateTime = task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt;
162
+ time.textContent = `Updated ${formatTime(time.dateTime)}`;
159
163
  article.append(heading, project, summary, time);
160
164
  return article;
161
165
  }
@@ -163,12 +167,17 @@ function taskCard(task) {
163
167
  function render() {
164
168
  const project = elements.projectFilter.value;
165
169
  const status = elements.statusFilter.value;
170
+ const date = elements.dateFilter.value;
166
171
  const visible = state.tasks.filter((task) =>
167
172
  (!project || task.project.name === project)
168
- && (!status || statusLabel(task) === status));
173
+ && (!status || taskStatusLabel(task) === status)
174
+ && taskWithinDateFilter(task, date));
169
175
  elements.taskList.replaceChildren(...visible.map(taskCard));
170
176
  elements.emptyState.hidden = visible.length > 0;
171
177
  elements.taskCount.textContent = `${visible.length} of ${state.tasks.length} task${state.tasks.length === 1 ? "" : "s"}`;
178
+ clearTimeout(dateRefreshTimer);
179
+ const refreshDelay = nextDateFilterRefreshDelay(visible, date);
180
+ dateRefreshTimer = refreshDelay === null ? null : setTimeout(render, refreshDelay);
172
181
  }
173
182
 
174
183
  function applySnapshot(snapshot) {
@@ -193,7 +202,7 @@ function applySnapshot(snapshot) {
193
202
  );
194
203
  replaceOptions(
195
204
  elements.statusFilter,
196
- [...new Set(state.tasks.map(statusLabel))].sort(),
205
+ [...new Set([...KNOWN_TASK_STATUSES, ...state.tasks.map(taskStatusLabel)])],
197
206
  "All statuses",
198
207
  );
199
208
  if (state.selectedTask) {
@@ -222,6 +231,7 @@ events.addEventListener("dashboard-error", (event) => {
222
231
 
223
232
  elements.projectFilter.addEventListener("change", render);
224
233
  elements.statusFilter.addEventListener("change", render);
234
+ elements.dateFilter.addEventListener("change", render);
225
235
  elements.clearNotifications.addEventListener("click", () => {
226
236
  state.notifications = [];
227
237
  renderNotifications();
@@ -244,7 +254,7 @@ elements.openProject.addEventListener("click", async () => {
244
254
  if (!state.selectedTask) return;
245
255
  elements.openProject.disabled = true;
246
256
  try {
247
- const response = await fetch(`/api/tasks/${encodeURIComponent(state.selectedTask.id)}/open-project`, {
257
+ const response = await fetch(`/api/tasks/${encodeURIComponent(state.selectedTask.id)}/open-codex`, {
248
258
  method: "POST",
249
259
  });
250
260
  const result = await response.json();
@@ -35,6 +35,14 @@
35
35
  <option value="">All statuses</option>
36
36
  </select>
37
37
  </label>
38
+ <label>
39
+ Updated
40
+ <select id="date-filter">
41
+ <option value="24h">Latest 24 hours</option>
42
+ <option value="7d">Latest 7 days</option>
43
+ <option value="all" selected>All time</option>
44
+ </select>
45
+ </label>
38
46
  <p id="task-count" class="task-count" aria-live="polite"></p>
39
47
  </section>
40
48
 
@@ -66,7 +74,7 @@
66
74
  <button id="close-dialog" class="icon-button" type="button" aria-label="Close task details">×</button>
67
75
  </div>
68
76
  <div class="dialog-actions">
69
- <button id="open-project" class="primary-button" type="button">Open project in Codex</button>
77
+ <button id="open-codex" class="primary-button" type="button">Open task in Codex</button>
70
78
  <button id="copy-thread-id" class="secondary-button" type="button">Copy thread ID</button>
71
79
  </div>
72
80
  <section>
@@ -1,4 +1,43 @@
1
1
  export const MAX_NOTIFICATIONS = 50;
2
+ export const KNOWN_TASK_STATUSES = [
3
+ "working",
4
+ "needs input",
5
+ "completed",
6
+ "failed",
7
+ "unresolved",
8
+ ];
9
+
10
+ const DATE_WINDOWS_MS = new Map([
11
+ ["24h", 24 * 60 * 60 * 1_000],
12
+ ["7d", 7 * 24 * 60 * 60 * 1_000],
13
+ ["all", null],
14
+ ]);
15
+
16
+ function taskMeaningfulTime(task) {
17
+ return Date.parse(task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt);
18
+ }
19
+
20
+ export function taskStatusLabel(task) {
21
+ return task.status === null ? "unresolved" : task.status.replaceAll("_", " ");
22
+ }
23
+
24
+ export function taskWithinDateFilter(task, filter, now = Date.now()) {
25
+ const windowMs = DATE_WINDOWS_MS.get(filter);
26
+ if (windowMs === null) return true;
27
+ if (windowMs === undefined) return false;
28
+ const meaningfulTime = taskMeaningfulTime(task);
29
+ return Number.isFinite(meaningfulTime) && meaningfulTime >= now - windowMs;
30
+ }
31
+
32
+ export function nextDateFilterRefreshDelay(tasks, filter, now = Date.now()) {
33
+ const windowMs = DATE_WINDOWS_MS.get(filter);
34
+ if (windowMs === null || windowMs === undefined) return null;
35
+ const nextCutoff = tasks
36
+ .map((task) => taskMeaningfulTime(task) + windowMs - now)
37
+ .filter((delay) => Number.isFinite(delay) && delay >= 0)
38
+ .reduce((minimum, delay) => Math.min(minimum, delay), Number.POSITIVE_INFINITY);
39
+ return Number.isFinite(nextCutoff) ? nextCutoff + 1 : null;
40
+ }
2
41
 
3
42
  export function taskSignature(task) {
4
43
  return JSON.stringify([
package/src/dashboard.js CHANGED
@@ -6,7 +6,11 @@ import http from "node:http";
6
6
  import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
 
9
- import { openWorkspaceInCodex } from "./codex-app.js";
9
+ import {
10
+ isCodexThreadDeepLinkId,
11
+ openThreadInCodex,
12
+ openWorkspaceInCodex,
13
+ } from "./codex-app.js";
10
14
  import {
11
15
  canonicalDirectory,
12
16
  canonicalGitRoot,
@@ -184,7 +188,12 @@ export class DashboardMonitor extends EventEmitter {
184
188
  revision: this.revision,
185
189
  generatedAt: new Date().toISOString(),
186
190
  healthy: !this.unhealthy,
187
- tasks: this.tasks,
191
+ tasks: this.tasks.map((task) => ({
192
+ ...task,
193
+ meaningfulUpdatedAt: new Date(
194
+ meaningfulUpdateTime(task, this.observedUpdateTimes),
195
+ ).toISOString(),
196
+ })),
188
197
  };
189
198
  }
190
199
 
@@ -395,6 +404,7 @@ export async function createDashboardServer({
395
404
  port = 3210,
396
405
  monitorOptions = {},
397
406
  openProject = null,
407
+ openThread = null,
398
408
  } = {}) {
399
409
  if (!LOOPBACK_HOSTS.has(host)) {
400
410
  throw new Error("dashboard host must be a loopback address");
@@ -501,7 +511,7 @@ export async function createDashboardServer({
501
511
  return;
502
512
  }
503
513
 
504
- const taskMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)\/open-project$/);
514
+ const taskMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)\/open-codex$/);
505
515
  if (taskMatch && method === "POST") {
506
516
  if (request.headers.origin !== allowedOrigin) {
507
517
  sendJson(response, 403, { message: "Dashboard session validation failed." });
@@ -513,6 +523,12 @@ export async function createDashboardServer({
513
523
  return;
514
524
  }
515
525
  try {
526
+ if (isCodexThreadDeepLinkId(task.threadId)) {
527
+ if (openThread) await openThread(task.threadId);
528
+ else await openThreadInCodex(task.threadId);
529
+ sendJson(response, 202, { message: "Opened this task in Codex." });
530
+ return;
531
+ }
516
532
  const trustedProject = (await readConfig(monitor.workspace, { checkPaths: false })).projects
517
533
  .find((project) => project.path === task.project.path);
518
534
  if (!trustedProject) {
@@ -533,11 +549,13 @@ export async function createDashboardServer({
533
549
  if (openProject) await openProject(canonicalProjectPath);
534
550
  else await openWorkspaceInCodex(canonicalProjectPath);
535
551
  sendJson(response, 202, {
536
- message: "Opened the project in Codex. Select the recorded task there.",
552
+ message: task.threadId
553
+ ? "Opened the project in Codex; this legacy thread ID cannot use direct navigation."
554
+ : "Opened the project in Codex; this task does not yet have a thread ID.",
537
555
  });
538
556
  } catch {
539
557
  sendJson(response, 503, {
540
- message: "Codex could not be opened. Run codex app with the project path instead.",
558
+ message: "Codex could not be opened. Open the project and select the recorded thread instead.",
541
559
  });
542
560
  }
543
561
  return;