taskchef 7.18.0 → 7.19.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": "7.18.0",
3
+ "version": "7.19.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
@@ -263,6 +263,12 @@ The loopback dashboard watches `tasks.jsonl`, groups current states, and opens
263
263
  linked Codex tasks. List snapshots and SSE events carry only the latest
264
264
  request/result pair; opening task details fetches the full newest-first activity
265
265
  timeline, including clearly labeled interrupted turns.
266
+ Task details also offer **Archive chat** for every linked task whose current
267
+ TaskChef state is not `working`. After confirmation, TaskChef invokes only the
268
+ Codex CLI at the canonical ChatGPT or Codex desktop app location under
269
+ `/Applications`, to archive the exact thread UUID. The Codex chat leaves active chat lists while the TaskChef record
270
+ and its activity timeline remain unchanged. If the bundled CLI is unavailable,
271
+ the dashboard does not fall back to another `codex` executable from `PATH`.
266
272
  The header shows the running TaskChef package version reported by the same
267
273
  bounded health identity used for compatible-listener checks.
268
274
  Task and result times are relative through 29 days (with minute detail for the
package/docs/spec.md CHANGED
@@ -427,6 +427,17 @@ startup safely. Direct thread navigation
427
427
  MUST require a canonical Codex UUIDv7. Otherwise it MAY open the revalidated
428
428
  configured project. Project paths from task history MUST be matched against
429
429
  current configuration before use.
430
+ The task-detail dashboard MAY offer Codex chat archival only when the stored
431
+ thread ID is a canonical Codex UUID and the current TaskChef state is not
432
+ `working`. It MUST revalidate both conditions for the POST request, require the
433
+ exact loopback origin, and obtain explicit user confirmation in the browser.
434
+ It MUST invoke the `archive` subcommand by directly executing only a Codex CLI
435
+ inside the canonical ChatGPT or Codex desktop application bundle under macOS
436
+ `/Applications`, with the thread UUID as a separate argument. It MUST NOT use a shell, a generic `PATH` fallback, a
437
+ private desktop endpoint, or direct session-file manipulation. Successful
438
+ archival MUST NOT modify dispatcher files or remove the TaskChef task record.
439
+ The UI MUST disclose that spawned descendant chats may also be archived and
440
+ that TaskChef history remains available.
430
441
  Snapshot and SSE list payloads MUST omit full `turns` and derived `results`
431
442
  history and include `latestTurn`. The bounded per-task detail endpoint MAY
432
443
  return the full validated task so the dialog can render the paired timeline newest first.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.18.0",
3
+ "version": "7.19.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
@@ -1,14 +1,21 @@
1
1
  import { execFile as execFileCallback } from "node:child_process";
2
- import { access } from "node:fs/promises";
2
+ import { access, realpath } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { promisify } from "node:util";
5
5
 
6
+ import { normalizeCodexThreadId } from "./delegation.js";
7
+
6
8
  const execFile = promisify(execFileCallback);
7
9
  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;
10
+ const CODEX_ARCHIVE_TIMEOUT_MS = 4_000;
11
+ const CODEX_COMMAND_MAX_BUFFER_BYTES = 64 * 1024;
9
12
 
10
- function runCodex(run, filePath, args) {
11
- return run(filePath, args, { timeout: CODEX_COMMAND_TIMEOUT_MS, killSignal: "SIGKILL" });
13
+ function runCodex(run, filePath, args, timeout = CODEX_COMMAND_TIMEOUT_MS) {
14
+ return run(filePath, args, {
15
+ timeout,
16
+ killSignal: "SIGKILL",
17
+ maxBuffer: CODEX_COMMAND_MAX_BUFFER_BYTES,
18
+ });
12
19
  }
13
20
 
14
21
  async function executable(filePath) {
@@ -20,15 +27,29 @@ async function executable(filePath) {
20
27
  }
21
28
  }
22
29
 
23
- function pathCandidates(env) {
30
+ function pathCandidates(env, platform = process.platform) {
24
31
  return (env.PATH ?? "")
25
32
  .split(path.delimiter)
26
33
  .filter(Boolean)
27
- .map((directory) => path.join(directory, process.platform === "win32" ? "codex.exe" : "codex"));
34
+ .map((directory) => path.join(directory, platform === "win32" ? "codex.exe" : "codex"));
28
35
  }
29
36
 
30
37
  function isDesktopBundleCandidate(filePath) {
31
- return filePath.includes(`${path.sep}Contents${path.sep}Resources${path.sep}`);
38
+ if (path.basename(filePath) !== "codex") return false;
39
+ const resources = path.dirname(filePath);
40
+ const contents = path.dirname(resources);
41
+ const application = path.dirname(contents);
42
+ return path.basename(resources) === "Resources"
43
+ && path.basename(contents) === "Contents"
44
+ && new Set(["ChatGPT.app", "Codex.app"]).has(path.basename(application));
45
+ }
46
+
47
+ function defaultDesktopBundleCandidates(_env, platform) {
48
+ if (platform !== "darwin") return [];
49
+ return [
50
+ "/Applications/ChatGPT.app/Contents/Resources/codex",
51
+ "/Applications/Codex.app/Contents/Resources/codex",
52
+ ];
32
53
  }
33
54
 
34
55
  async function supportsAppCommand(filePath, run) {
@@ -40,6 +61,37 @@ async function supportsAppCommand(filePath, run) {
40
61
  }
41
62
  }
42
63
 
64
+ async function supportsArchiveCommand(filePath, run) {
65
+ try {
66
+ const { stdout, stderr } = await runCodex(run, filePath, ["archive", "--help"]);
67
+ return /(?:^|\n)Usage:\s+codex\s+archive(?:\s|$)/.test(`${stdout}\n${stderr}`);
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ export async function discoverBundledCodexCli({
74
+ candidates = null,
75
+ env = process.env,
76
+ platform = process.platform,
77
+ run = execFile,
78
+ trustedBundlePaths = null,
79
+ } = {}) {
80
+ const trustedPaths = trustedBundlePaths ?? defaultDesktopBundleCandidates(env, platform);
81
+ const trustedCandidates = new Set(trustedPaths.map((candidate) => path.resolve(candidate)));
82
+ const bundledCandidates = candidates ?? trustedPaths;
83
+ for (const filePath of new Set(bundledCandidates.map((candidate) => path.resolve(candidate)))) {
84
+ const executableCandidate = await executable(filePath);
85
+ const candidate = executableCandidate === null ? null : await realpath(executableCandidate);
86
+ if (platform === "darwin" && candidate && trustedCandidates.has(candidate)
87
+ && isDesktopBundleCandidate(candidate)
88
+ && await supportsArchiveCommand(candidate, run)) {
89
+ return { path: candidate, source: "desktop-bundle" };
90
+ }
91
+ }
92
+ throw new Error("Chat archiving requires the Codex CLI bundled with the ChatGPT or Codex desktop app");
93
+ }
94
+
43
95
  export async function discoverCodexCli({
44
96
  explicit = null,
45
97
  env = process.env,
@@ -56,7 +108,7 @@ export async function discoverCodexCli({
56
108
  }
57
109
 
58
110
  const candidates = pathCandidates(env);
59
- for (const pathCandidate of candidates.filter(isDesktopBundleCandidate)) {
111
+ for (const pathCandidate of candidates.filter((candidate) => isDesktopBundleCandidate(candidate))) {
60
112
  const candidate = await executable(pathCandidate);
61
113
  if (candidate && await supportsAppCommand(candidate, run)) {
62
114
  return { path: candidate, source: "desktop-path" };
@@ -89,7 +141,12 @@ export async function openWorkspaceInCodex(workspace, options = {}) {
89
141
  }
90
142
 
91
143
  export function isCodexThreadDeepLinkId(threadId) {
92
- return typeof threadId === "string" && CODEX_THREAD_ID_PATTERN.test(threadId);
144
+ try {
145
+ normalizeCodexThreadId(threadId);
146
+ return true;
147
+ } catch {
148
+ return false;
149
+ }
93
150
  }
94
151
 
95
152
  export async function openThreadInCodex(threadId, options = {}) {
@@ -108,3 +165,24 @@ export async function openThreadInCodex(threadId, options = {}) {
108
165
  }
109
166
  return { status: "requested", mechanism: "codex-deep-link", threadId, url };
110
167
  }
168
+
169
+ export async function archiveThreadInCodex(threadId, options = {}) {
170
+ if (!isCodexThreadDeepLinkId(threadId)) {
171
+ throw new Error("Codex thread ID is not supported by chat archiving");
172
+ }
173
+ const run = options.run ?? execFile;
174
+ const cli = options.cli ?? await discoverBundledCodexCli({ run });
175
+ await runCodex(
176
+ run,
177
+ cli.path,
178
+ ["archive", threadId],
179
+ options.timeoutMs ?? CODEX_ARCHIVE_TIMEOUT_MS,
180
+ );
181
+ return {
182
+ status: "archived",
183
+ mechanism: "codex-archive-cli",
184
+ codexCli: cli.path,
185
+ codexCliSource: cli.source,
186
+ threadId,
187
+ };
188
+ }
@@ -15,3 +15,41 @@ export async function openTaskFromControl(event, taskId, {
15
15
  control.disabled = false;
16
16
  }
17
17
  }
18
+
19
+ export async function archiveTaskFromControl(event, task, {
20
+ confirmAction = globalThis.confirm,
21
+ fetchAction = globalThis.fetch,
22
+ onArchived = () => {},
23
+ showMessage,
24
+ } = {}) {
25
+ event.stopPropagation();
26
+ const accepted = confirmAction(
27
+ `Archive “${task.title}” in Codex?\n\n`
28
+ + "The chat will leave active Codex lists, and spawned descendant chats may also be archived. "
29
+ + "TaskChef history will remain available.",
30
+ );
31
+ if (!accepted) return false;
32
+ const control = event.currentTarget;
33
+ let archived = false;
34
+ control.disabled = true;
35
+ try {
36
+ const response = await fetchAction(
37
+ `/api/tasks/${encodeURIComponent(task.id)}/archive-codex`,
38
+ { method: "POST" },
39
+ );
40
+ const result = await response.json();
41
+ if (!response.ok) {
42
+ showMessage(result.message ?? "Codex could not archive this chat.");
43
+ return false;
44
+ }
45
+ onArchived(task.threadId);
46
+ archived = true;
47
+ showMessage(result.message ?? "Archived the Codex chat. TaskChef history remains available.");
48
+ return true;
49
+ } catch {
50
+ showMessage("Codex chat archiving is temporarily unavailable. Try again.");
51
+ return false;
52
+ } finally {
53
+ if (!archived) control.disabled = false;
54
+ }
55
+ }
@@ -1,4 +1,5 @@
1
1
  import {
2
+ canArchiveTask,
2
3
  clearNotifications,
3
4
  dismissNotification,
4
5
  filterTasks,
@@ -15,7 +16,7 @@ import {
15
16
  taskStatusLabel,
16
17
  turnPresentation,
17
18
  } from "./state.js";
18
- import { openTaskFromControl } from "./actions.js";
19
+ import { archiveTaskFromControl, openTaskFromControl } from "./actions.js";
19
20
  import {
20
21
  githubReferenceAccessibleLabel,
21
22
  githubReferenceDisplayLabels,
@@ -25,6 +26,7 @@ import {
25
26
  import { formatRelativeTime, RelativeTimeController, parsedTimestamp } from "./time.js";
26
27
 
27
28
  const state = {
29
+ archivedThreadIds: new Set(),
28
30
  tasks: [],
29
31
  signatures: new Map(),
30
32
  notifications: [],
@@ -39,6 +41,7 @@ let copyTaskIdTimer = null;
39
41
  const relativeTimes = new RelativeTimeController();
40
42
 
41
43
  const elements = {
44
+ archiveTask: document.querySelector("#archive-codex"),
42
45
  clearNotifications: document.querySelector("#clear-notifications"),
43
46
  closeDialog: document.querySelector("#close-dialog"),
44
47
  connectionDot: document.querySelector("#connection-dot"),
@@ -446,6 +449,15 @@ function renderDialog(task) {
446
449
  ...detailRow("Updated by", task.updatedBy),
447
450
  );
448
451
  configureOpenTaskControl(elements.openProject, `Open ${task.title} in Codex`);
452
+ const canArchive = canArchiveTask(task);
453
+ const archived = state.archivedThreadIds.has(task.threadId);
454
+ elements.archiveTask.hidden = !canArchive;
455
+ elements.archiveTask.disabled = archived;
456
+ elements.archiveTask.textContent = archived ? "Archived" : "Archive chat";
457
+ elements.archiveTask.setAttribute(
458
+ "aria-label",
459
+ archived ? `${task.title} is archived in Codex` : `Archive ${task.title} in Codex`,
460
+ );
449
461
  }
450
462
 
451
463
  async function openDialog(task) {
@@ -652,3 +664,14 @@ elements.openProject.addEventListener("click", async (event) => {
652
664
  if (!state.selectedTask) return;
653
665
  await openTaskFromControl(event, state.selectedTask.id, { showMessage });
654
666
  });
667
+ elements.archiveTask.addEventListener("click", async (event) => {
668
+ const task = state.selectedTask;
669
+ if (!task || !canArchiveTask(task)) return;
670
+ await archiveTaskFromControl(event, task, {
671
+ onArchived: (threadId) => {
672
+ state.archivedThreadIds.add(threadId);
673
+ if (state.selectedTask?.id === task.id) renderDialog(state.selectedTask);
674
+ },
675
+ showMessage,
676
+ });
677
+ });
@@ -113,6 +113,7 @@
113
113
  <span>Open task</span>
114
114
  </button>
115
115
  <button id="copy-task-id" class="secondary-button" type="button" aria-label="Copy Task ID">Copy Task ID</button>
116
+ <button id="archive-codex" class="danger-button" type="button" aria-label="Archive this chat in Codex" hidden>Archive chat</button>
116
117
  </div>
117
118
  <nav id="dialog-related-links" class="github-links" aria-label="Related GitHub links" hidden></nav>
118
119
  <section>
@@ -13,6 +13,7 @@ export const STATUS_FILTERS = [
13
13
  { value: "completed", label: "Completed" },
14
14
  { value: "failed", label: "Failed" },
15
15
  ];
16
+ const CODEX_THREAD_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
16
17
 
17
18
  const NOTIFICATION_TITLES = new Map([
18
19
  ["created", "Task created"],
@@ -38,6 +39,12 @@ export function taskStatusLabel(task) {
38
39
  return task.status === null ? "unresolved" : task.status.replaceAll("_", " ");
39
40
  }
40
41
 
42
+ export function canArchiveTask(task) {
43
+ return task.status !== "working"
44
+ && typeof task.threadId === "string"
45
+ && CODEX_THREAD_ID_PATTERN.test(task.threadId);
46
+ }
47
+
41
48
  export function latestTurnPresentation(task) {
42
49
  const turn = task.latestTurn ?? null;
43
50
  const result = turn?.result ?? null;
@@ -149,10 +149,11 @@ dialog::backdrop { background: rgb(18 23 21 / 50%); backdrop-filter: blur(2px);
149
149
  .dialog-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
150
150
  .dialog-header h2 { margin-bottom: 12px; font-size: 1.6rem; }
151
151
  .dialog-actions { display: flex; gap: 8px; margin-bottom: 26px; }
152
- .primary-button, .secondary-button { padding: 8px 12px; border-radius: 7px; font-weight: 700; cursor: pointer; }
152
+ .primary-button, .secondary-button, .danger-button { padding: 8px 12px; border-radius: 7px; font-weight: 700; cursor: pointer; }
153
153
  .primary-button { border: 1px solid var(--accent); background: var(--accent); color: white; }
154
154
  .secondary-button { border: 1px solid var(--border); background: var(--surface); }
155
- .primary-button:disabled, .secondary-button:disabled { opacity: 0.5; cursor: not-allowed; }
155
+ .danger-button { border: 1px solid var(--danger); background: var(--surface); color: var(--danger); }
156
+ .primary-button:disabled, .secondary-button:disabled, .danger-button:disabled { opacity: 0.5; cursor: not-allowed; }
156
157
  dialog section + section { margin-top: 24px; }
157
158
  .preserve-lines { white-space: pre-wrap; }
158
159
  .result-history { display: grid; gap: 10px; }
package/src/dashboard.js CHANGED
@@ -6,15 +6,19 @@ import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
 
8
8
  import {
9
+ archiveThreadInCodex,
10
+ discoverBundledCodexCli,
9
11
  isCodexThreadDeepLinkId,
10
12
  openThreadInCodex,
11
13
  openWorkspaceInCodex,
12
14
  } from "./codex-app.js";
13
15
  import {
16
+ acquireWorkspaceLock,
14
17
  canonicalDirectory,
15
18
  canonicalGitRoot,
16
19
  parseTaskLogContent,
17
20
  readConfig,
21
+ readTask,
18
22
  } from "./workspace.js";
19
23
  import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
20
24
  import { taskGitHubProjection } from "./dashboard/github-links.js";
@@ -439,6 +443,8 @@ function publicMonitorError() {
439
443
  }
440
444
 
441
445
  export async function createDashboardServer({
446
+ archiveThread = archiveThreadInCodex,
447
+ discoverArchiveCli = discoverBundledCodexCli,
442
448
  workspace,
443
449
  host = "127.0.0.1",
444
450
  maxEventClients = DEFAULT_MAX_EVENT_CLIENTS,
@@ -472,6 +478,7 @@ export async function createDashboardServer({
472
478
  throw new Error("dashboard identity exceeds the health response limit");
473
479
  }
474
480
  const clients = new Set();
481
+ const archiveRequests = new Set();
475
482
  let allowedAuthority;
476
483
  let allowedOrigin;
477
484
 
@@ -617,6 +624,77 @@ export async function createDashboardServer({
617
624
  return;
618
625
  }
619
626
 
627
+ const archiveMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)\/archive-codex$/);
628
+ if (archiveMatch && method === "POST") {
629
+ if (request.headers.origin !== allowedOrigin) {
630
+ sendJson(response, 403, { message: "Dashboard origin validation failed." });
631
+ return;
632
+ }
633
+ const taskId = archiveMatch[1];
634
+ const visibleTask = monitor.tasks.find((candidate) => candidate.id === taskId);
635
+ if (!visibleTask) {
636
+ sendJson(response, 404, { message: "Task not found." });
637
+ return;
638
+ }
639
+ if (!isCodexThreadDeepLinkId(visibleTask.threadId)) {
640
+ sendJson(response, 409, { message: "This task does not have an archivable Codex chat." });
641
+ return;
642
+ }
643
+ if (visibleTask.status === "working") {
644
+ sendJson(response, 409, { message: "Working tasks cannot be archived from the dashboard." });
645
+ return;
646
+ }
647
+ if (archiveRequests.has(taskId)) {
648
+ sendJson(response, 409, { message: "This Codex chat is already being archived." });
649
+ return;
650
+ }
651
+ archiveRequests.add(taskId);
652
+ let releaseWorkspaceLock = null;
653
+ try {
654
+ const cli = await discoverArchiveCli();
655
+ releaseWorkspaceLock = await acquireWorkspaceLock(monitor.workspace);
656
+ let task;
657
+ try {
658
+ task = await readTask(monitor.workspace, taskId);
659
+ } catch (error) {
660
+ if (/^task not found:/.test(error?.message ?? "")) {
661
+ sendJson(response, 404, { message: "Task not found." });
662
+ return;
663
+ }
664
+ throw error;
665
+ }
666
+ if (!isCodexThreadDeepLinkId(task.threadId)) {
667
+ sendJson(response, 409, { message: "This task does not have an archivable Codex chat." });
668
+ return;
669
+ }
670
+ if (task.status === "working") {
671
+ sendJson(response, 409, { message: "Working tasks cannot be archived from the dashboard." });
672
+ return;
673
+ }
674
+ await archiveThread(task.threadId, { cli, timeoutMs: 4_000 });
675
+ sendJson(response, 200, {
676
+ message: "Archived the Codex chat. TaskChef history remains available.",
677
+ status: "archived",
678
+ });
679
+ } catch (error) {
680
+ if (error?.killed || error?.code === "ETIMEDOUT") {
681
+ sendJson(response, 504, { message: "Codex chat archiving timed out. Try again." });
682
+ } else if (/requires the Codex CLI bundled/i.test(error?.message ?? "")) {
683
+ sendJson(response, 503, {
684
+ message: "Chat archiving requires the Codex CLI bundled with the ChatGPT or Codex desktop app.",
685
+ });
686
+ } else {
687
+ sendJson(response, 409, {
688
+ message: "Codex could not archive this chat. It may be active, already archived, or stored on another host or profile.",
689
+ });
690
+ }
691
+ } finally {
692
+ await releaseWorkspaceLock?.();
693
+ archiveRequests.delete(taskId);
694
+ }
695
+ return;
696
+ }
697
+
620
698
  if (method === "POST") {
621
699
  sendJson(response, 404, { message: "Not found." });
622
700
  return;
package/src/version.js CHANGED
@@ -4,4 +4,4 @@ const require = createRequire(import.meta.url);
4
4
  const packageMetadata = require("../package.json");
5
5
 
6
6
  export const TASKCHEF_VERSION = packageMetadata.version;
7
- export const DASHBOARD_SERVER_VERSION = "1";
7
+ export const DASHBOARD_SERVER_VERSION = "2";