taskchef 7.18.0 → 7.19.1

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.1",
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,8 +263,19 @@ 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.
274
+ The canonical port is owned by a dashboard initialized in the TaskChef MCP host
275
+ before its tool transport connects. Health identity records an `mcp` launcher,
276
+ and MCP recovery reuses only another exact-compatible MCP-launched dashboard;
277
+ a foreground `taskchef dashboard` process is intentionally standalone so its
278
+ child commands cannot silently inherit an agent-shell sandbox.
268
279
  Task and result times are relative through 29 days (with minute detail for the
269
280
  first six hours), then use a locale-aware calendar date. Each time is a keyboard-
270
281
  accessible toggle for its full locale-aware date and time, and one shared
@@ -275,16 +286,17 @@ revision, so reconnects and non-semantic rewrites do not replay a notice and a
275
286
  later task state cannot rewrite an older notice. A notice remains readable if
276
287
  its task disappears; selecting it then explains that current details are no
277
288
  longer available.
278
- It does not mutate TaskChef data and prints its local URL.
279
- When a compatible foreground dashboard already owns port 3210,
280
- `ensure_dashboard` reuses it but does not take ownership. If an unknown,
281
- different-workspace, or stale-version process owns the port, TaskChef reports a
282
- concise conflict and never kills or replaces that process. The foreground CLI
283
- similarly asks you to stop the listener or choose another `--port`.
289
+ It does not mutate TaskChef data and prints its local URL. A foreground
290
+ dashboard identifies itself as standalone and is never reused on the canonical
291
+ MCP port. If a standalone, unknown, different-workspace, or stale-version
292
+ process owns port 3210, TaskChef reports a concise conflict and never kills or
293
+ replaces that process. The foreground CLI similarly asks you to stop the
294
+ listener or choose another `--port`.
284
295
 
285
296
  The health endpoint contains only a fixed service marker, health schema,
286
- TaskChef version, dashboard-server version, and canonical workspace. It exposes
287
- no task data, credentials, environment variables, process control, or secrets.
297
+ TaskChef version, dashboard-server version, canonical workspace, and launcher.
298
+ It exposes no task data, credentials, environment variables, process control,
299
+ or secrets.
288
300
 
289
301
  ![TaskChef dashboard identity and version](docs/images/dashboard-identity.jpg)
290
302
 
@@ -345,13 +357,15 @@ does not necessarily reload Codex. Then run `$taskchef-dashboard` (or call
345
357
 
346
358
  - the expected released TaskChef version;
347
359
  - the expected dashboard protocol `serverVersion`;
360
+ - the `mcp` dashboard launcher;
348
361
  - the canonical TaskChef workspace path;
349
362
  - the canonical `http://127.0.0.1:3210/` URL.
350
363
 
351
364
  The release-install sequence is therefore: install plugin, activate or reload
352
365
  the new MCP process, ensure the dashboard, then verify TaskChef version,
353
- protocol `serverVersion`, canonical workspace, and URL. Exact-compatible
354
- servers may be reused; unknown listeners remain untouched.
366
+ protocol `serverVersion`, `mcp` launcher, canonical workspace, and URL.
367
+ Exact-compatible MCP servers may be reused; standalone and unknown listeners
368
+ remain untouched.
355
369
 
356
370
  ## Development
357
371
 
package/docs/spec.md CHANGED
@@ -121,13 +121,16 @@ fields MUST NOT change after recording.
121
121
 
122
122
  ## Required lifecycle
123
123
 
124
- When the canonical TaskChef MCP server finishes connecting, it MUST invoke the
125
- same serialized dashboard ensure path once by default. It MUST read the
124
+ Before the canonical TaskChef MCP server connects and exposes its transport, it
125
+ MUST invoke the same serialized dashboard ensure path once by default. It MUST read the
126
126
  canonical configuration and skip this only for explicit
127
127
  `dashboard.autostart: false`. Initialization failures, invalid workspace state,
128
128
  port conflicts, and dashboard errors MUST NOT prevent tool registration or MCP
129
129
  availability. They MUST emit only a bounded non-sensitive diagnostic through
130
130
  the MCP process logging channel. MCP initialization MUST NOT open a browser.
131
+ If transport connection fails after dashboard startup, the MCP server MUST
132
+ best-effort close both its owned dashboard and partially attached transport,
133
+ then propagate the original connection failure even when cleanup also fails.
131
134
 
132
135
  At the start of every dispatcher turn, the dispatcher SHOULD call
133
136
  `ensure_dashboard` best-effort. Failure MUST NOT block direct TaskChef answers,
@@ -228,6 +231,7 @@ HTTP server; it does not mutate dispatcher workspace files.
228
231
  ```text
229
232
  { dashboard: {
230
233
  action: "started" | "reused",
234
+ launcher: "mcp",
231
235
  url: "http://127.0.0.1:3210/",
232
236
  workspace: string,
233
237
  taskchefVersion: string,
@@ -235,20 +239,22 @@ HTTP server; it does not mutate dispatcher workspace files.
235
239
  } }
236
240
  ```
237
241
 
238
- Calls MUST serialize within one MCP process. The first call starts an owned
239
- dashboard or reuses an exact compatible listener; later and concurrent calls
240
- are idempotent and report reuse after the single start. The stable default MUST
242
+ MCP initialization MUST complete its best-effort dashboard start before exposing
243
+ the MCP transport. Calls MUST serialize within one MCP process. The first call
244
+ starts an owned dashboard or reuses an exact compatible MCP-launched listener;
245
+ later and concurrent calls are idempotent and report reuse after the single start. The stable default MUST
241
246
  bind only to `127.0.0.1:3210` and MUST NOT accept a model-supplied workspace,
242
247
  host, or port.
243
248
 
244
249
  Before reuse, TaskChef MUST query a bounded loopback identity endpoint and
245
250
  require the exact fixed service/schema, TaskChef version, dashboard-server
246
- version, and canonical workspace. An unknown, malformed, different-workspace,
247
- or stale-version listener MUST produce a concise actionable conflict. TaskChef
251
+ version, canonical workspace, and `mcp` launcher identity. A standalone,
252
+ unknown, malformed, different-workspace, or stale-version listener MUST produce a concise actionable conflict. TaskChef
248
253
  MUST NOT kill, replace, signal, or otherwise control that listener. A startup
249
254
  failure MUST leave no owned listener. The MCP server MUST close its owned
250
- dashboard when its transport or process shuts down; it MUST NOT close a reused
251
- external foreground server.
255
+ dashboard when its transport or process shuts down. This keeps dashboard child
256
+ operations in the MCP host environment and prevents an agent-shell or foreground
257
+ CLI dashboard from being mistaken for the canonical MCP-owned runtime.
252
258
 
253
259
  The packaged `$taskchef-dashboard` skill MUST call this tool, report `started`
254
260
  or `reused`, and return the canonical clickable URL. It MAY use an available
@@ -427,6 +433,17 @@ startup safely. Direct thread navigation
427
433
  MUST require a canonical Codex UUIDv7. Otherwise it MAY open the revalidated
428
434
  configured project. Project paths from task history MUST be matched against
429
435
  current configuration before use.
436
+ The task-detail dashboard MAY offer Codex chat archival only when the stored
437
+ thread ID is a canonical Codex UUID and the current TaskChef state is not
438
+ `working`. It MUST revalidate both conditions for the POST request, require the
439
+ exact loopback origin, and obtain explicit user confirmation in the browser.
440
+ It MUST invoke the `archive` subcommand by directly executing only a Codex CLI
441
+ inside the canonical ChatGPT or Codex desktop application bundle under macOS
442
+ `/Applications`, with the thread UUID as a separate argument. It MUST NOT use a shell, a generic `PATH` fallback, a
443
+ private desktop endpoint, or direct session-file manipulation. Successful
444
+ archival MUST NOT modify dispatcher files or remove the TaskChef task record.
445
+ The UI MUST disclose that spawned descendant chats may also be archived and
446
+ that TaskChef history remains available.
430
447
  Snapshot and SSE list payloads MUST omit full `turns` and derived `results`
431
448
  history and include `latestTurn`. The bounded per-task detail endpoint MAY
432
449
  return the full validated task so the dialog can render the paired timeline newest first.
@@ -499,6 +516,6 @@ closed.
499
516
  Installing or replacing plugin files MUST NOT be described as activating the
500
517
  new MCP code. Release verification MUST install the plugin, activate or reload
501
518
  the new MCP process, ensure the dashboard, and verify the expected TaskChef
502
- version, dashboard protocol `serverVersion`, canonical workspace, and canonical
503
- URL. Exact-compatible listener reuse remains valid; installation MUST NOT be
519
+ version, dashboard protocol `serverVersion`, `mcp` launcher, canonical workspace,
520
+ and canonical URL. Exact-compatible MCP-listener reuse remains valid; installation MUST NOT be
504
521
  claimed to reload Codex automatically.
package/docs/workflows.md CHANGED
@@ -40,11 +40,11 @@ sequenceDiagram
40
40
  participant H as Loopback health
41
41
  participant S as Dashboard server
42
42
  M->>M: Read dashboard.autostart (absent means true)
43
- M->>M: Best-effort ensure after MCP connect
43
+ M->>M: Best-effort ensure before MCP transport connects
44
44
  D->>M: ensure_dashboard()
45
45
  M->>M: Serialize concurrent ensure calls
46
46
  M->>H: GET 127.0.0.1:3210/api/health
47
- alt Exact service, versions, and canonical workspace
47
+ alt Exact service, versions, canonical workspace, and MCP launcher
48
48
  H-->>M: Bounded compatible identity
49
49
  M-->>D: reused, URL, workspace, versions
50
50
  else No listener
@@ -52,7 +52,7 @@ sequenceDiagram
52
52
  M->>S: Start in this MCP process on 127.0.0.1:3210
53
53
  S-->>M: Owned server
54
54
  M-->>D: started, URL, workspace, versions
55
- else Unknown, stale, or different workspace
55
+ else Standalone, unknown, stale, or different workspace
56
56
  H-->>M: Missing or incompatible identity
57
57
  M-->>D: Actionable conflict, listener untouched
58
58
  end
@@ -62,9 +62,10 @@ sequenceDiagram
62
62
  ```
63
63
 
64
64
  When the MCP transport or plugin process closes, it closes only the server it
65
- started. A compatible foreground `taskchef dashboard` listener may be reused
66
- but remains owned by that CLI process. No TaskChef path terminates an unknown
67
- listener or installs OS persistence.
65
+ started. A foreground `taskchef dashboard` listener identifies itself as
66
+ standalone and is never reused as the canonical MCP dashboard, because its
67
+ archive child process may inherit a different host environment. No TaskChef
68
+ path terminates an incompatible listener or installs OS persistence.
68
69
 
69
70
  Autostart and explicit ensures share the same manager promise, so concurrent
70
71
  initialization and recovery calls produce at most one owned listener. An
@@ -79,13 +80,13 @@ The practical release handoff ends in this order:
79
80
  1. Install the released plugin.
80
81
  2. Activate or reload its new TaskChef MCP process.
81
82
  3. Run `$taskchef-dashboard` or call `ensure_dashboard`.
82
- 4. Verify the expected TaskChef version, protocol `serverVersion`, canonical
83
- workspace, and canonical URL returned by the dashboard identity.
83
+ 4. Verify the expected TaskChef version, protocol `serverVersion`, `mcp`
84
+ launcher, canonical workspace, and canonical URL returned by the dashboard identity.
84
85
 
85
86
  Replacing plugin files alone cannot execute autostart because old code remains
86
87
  in the already-running MCP process. Installation does not necessarily reload
87
- Codex. An exact-compatible dashboard may be reused; an unknown listener is
88
- never terminated or replaced.
88
+ Codex. An exact-compatible MCP dashboard may be reused; a standalone or unknown
89
+ listener is never terminated or replaced.
89
90
 
90
91
  ## Normal delegation and self-linking
91
92
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.18.0",
3
+ "version": "7.19.1",
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; }
@@ -14,13 +14,14 @@ const DEFAULT_HOST = "127.0.0.1";
14
14
  const DEFAULT_PORT = 3210;
15
15
  const HEALTH_TIMEOUT_MS = 750;
16
16
 
17
- function expectedIdentity(workspace, taskchefVersion, serverVersion) {
17
+ function expectedIdentity(workspace, taskchefVersion, serverVersion, launcher) {
18
18
  return {
19
19
  schemaVersion: 1,
20
20
  service: "taskchef-dashboard",
21
21
  taskchefVersion,
22
22
  serverVersion,
23
23
  workspace,
24
+ launcher,
24
25
  };
25
26
  }
26
27
 
@@ -107,6 +108,7 @@ export function createDashboardManager({
107
108
  port = DEFAULT_PORT,
108
109
  taskchefVersion = TASKCHEF_VERSION,
109
110
  serverVersion = DASHBOARD_SERVER_VERSION,
111
+ launcher = "mcp",
110
112
  createServer = createDashboardServer,
111
113
  readIdentity = readDashboardIdentity,
112
114
  } = {}) {
@@ -117,6 +119,7 @@ export function createDashboardManager({
117
119
 
118
120
  const publicResult = (action) => ({
119
121
  action,
122
+ launcher,
120
123
  url: `http://${dashboardAuthority(host, ownedServer?.port ?? port)}/`,
121
124
  workspace: canonicalWorkspace,
122
125
  taskchefVersion,
@@ -132,9 +135,12 @@ export function createDashboardManager({
132
135
  if (listenerAbsent(error)) return false;
133
136
  throw listenerConflict(url, `is occupied but did not return a compatible identity (${error.message}).`);
134
137
  }
135
- const expected = expectedIdentity(canonicalWorkspace, taskchefVersion, serverVersion);
138
+ const expected = expectedIdentity(canonicalWorkspace, taskchefVersion, serverVersion, launcher);
136
139
  if (!isExactIdentity(identity, expected)) {
137
- throw listenerConflict(url, "belongs to an unknown, stale, or different-workspace service.");
140
+ throw listenerConflict(
141
+ url,
142
+ "belongs to an unknown, stale, different-workspace, or differently launched service.",
143
+ );
138
144
  }
139
145
  return true;
140
146
  };
@@ -150,6 +156,7 @@ export function createDashboardManager({
150
156
  port,
151
157
  taskchefVersion,
152
158
  serverVersion,
159
+ launcher,
153
160
  });
154
161
  return publicResult("started");
155
162
  } catch (error) {
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,
@@ -446,6 +452,7 @@ export async function createDashboardServer({
446
452
  monitorOptions = {},
447
453
  openProject = null,
448
454
  openThread = null,
455
+ launcher = "standalone",
449
456
  taskchefVersion = TASKCHEF_VERSION,
450
457
  serverVersion = DASHBOARD_SERVER_VERSION,
451
458
  } = {}) {
@@ -458,6 +465,9 @@ export async function createDashboardServer({
458
465
  if (!Number.isInteger(maxEventClients) || maxEventClients < 0) {
459
466
  throw new Error("dashboard event-client limit must be a non-negative integer");
460
467
  }
468
+ if (!new Set(["mcp", "standalone"]).has(launcher)) {
469
+ throw new Error("dashboard launcher must be mcp or standalone");
470
+ }
461
471
  const monitor = new DashboardMonitor(workspace, monitorOptions);
462
472
  await monitor.start();
463
473
  const identity = Object.freeze({
@@ -466,12 +476,14 @@ export async function createDashboardServer({
466
476
  taskchefVersion,
467
477
  serverVersion,
468
478
  workspace: monitor.workspace,
479
+ launcher,
469
480
  });
470
481
  if (Buffer.byteLength(`${JSON.stringify(identity)}\n`) > DASHBOARD_HEALTH_MAX_BYTES) {
471
482
  monitor.close();
472
483
  throw new Error("dashboard identity exceeds the health response limit");
473
484
  }
474
485
  const clients = new Set();
486
+ const archiveRequests = new Set();
475
487
  let allowedAuthority;
476
488
  let allowedOrigin;
477
489
 
@@ -617,6 +629,77 @@ export async function createDashboardServer({
617
629
  return;
618
630
  }
619
631
 
632
+ const archiveMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)\/archive-codex$/);
633
+ if (archiveMatch && method === "POST") {
634
+ if (request.headers.origin !== allowedOrigin) {
635
+ sendJson(response, 403, { message: "Dashboard origin validation failed." });
636
+ return;
637
+ }
638
+ const taskId = archiveMatch[1];
639
+ const visibleTask = monitor.tasks.find((candidate) => candidate.id === taskId);
640
+ if (!visibleTask) {
641
+ sendJson(response, 404, { message: "Task not found." });
642
+ return;
643
+ }
644
+ if (!isCodexThreadDeepLinkId(visibleTask.threadId)) {
645
+ sendJson(response, 409, { message: "This task does not have an archivable Codex chat." });
646
+ return;
647
+ }
648
+ if (visibleTask.status === "working") {
649
+ sendJson(response, 409, { message: "Working tasks cannot be archived from the dashboard." });
650
+ return;
651
+ }
652
+ if (archiveRequests.has(taskId)) {
653
+ sendJson(response, 409, { message: "This Codex chat is already being archived." });
654
+ return;
655
+ }
656
+ archiveRequests.add(taskId);
657
+ let releaseWorkspaceLock = null;
658
+ try {
659
+ const cli = await discoverArchiveCli();
660
+ releaseWorkspaceLock = await acquireWorkspaceLock(monitor.workspace);
661
+ let task;
662
+ try {
663
+ task = await readTask(monitor.workspace, taskId);
664
+ } catch (error) {
665
+ if (/^task not found:/.test(error?.message ?? "")) {
666
+ sendJson(response, 404, { message: "Task not found." });
667
+ return;
668
+ }
669
+ throw error;
670
+ }
671
+ if (!isCodexThreadDeepLinkId(task.threadId)) {
672
+ sendJson(response, 409, { message: "This task does not have an archivable Codex chat." });
673
+ return;
674
+ }
675
+ if (task.status === "working") {
676
+ sendJson(response, 409, { message: "Working tasks cannot be archived from the dashboard." });
677
+ return;
678
+ }
679
+ await archiveThread(task.threadId, { cli, timeoutMs: 4_000 });
680
+ sendJson(response, 200, {
681
+ message: "Archived the Codex chat. TaskChef history remains available.",
682
+ status: "archived",
683
+ });
684
+ } catch (error) {
685
+ if (error?.killed || error?.code === "ETIMEDOUT") {
686
+ sendJson(response, 504, { message: "Codex chat archiving timed out. Try again." });
687
+ } else if (/requires the Codex CLI bundled/i.test(error?.message ?? "")) {
688
+ sendJson(response, 503, {
689
+ message: "Chat archiving requires the Codex CLI bundled with the ChatGPT or Codex desktop app.",
690
+ });
691
+ } else {
692
+ sendJson(response, 409, {
693
+ message: "Codex could not archive this chat. It may be active, already archived, or stored on another host or profile.",
694
+ });
695
+ }
696
+ } finally {
697
+ await releaseWorkspaceLock?.();
698
+ archiveRequests.delete(taskId);
699
+ }
700
+ return;
701
+ }
702
+
620
703
  if (method === "POST") {
621
704
  sendJson(response, 404, { message: "Not found." });
622
705
  return;
package/src/mcp.js CHANGED
@@ -88,6 +88,7 @@ const preparationSchema = z.object({
88
88
 
89
89
  const dashboardSchema = z.object({
90
90
  action: z.enum(["started", "reused"]),
91
+ launcher: z.literal("mcp"),
91
92
  url: z.string().url(),
92
93
  workspace: z.string(),
93
94
  taskchefVersion: z.string(),
@@ -163,7 +164,7 @@ export function createTaskChefMcpServer({
163
164
  return closePromise;
164
165
  };
165
166
  server.server.onclose = () => {
166
- void dashboardManager.close();
167
+ void dashboardManager.close().catch(() => {});
167
168
  };
168
169
 
169
170
  server.registerTool(
@@ -171,7 +172,7 @@ export function createTaskChefMcpServer({
171
172
  {
172
173
  title: "Ensure TaskChef dashboard",
173
174
  description:
174
- "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.",
175
+ "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 MCP-launched TaskChef dashboard for the same canonical workspace; standalone and unknown listeners are never terminated or replaced.",
175
176
  inputSchema: {},
176
177
  outputSchema: { dashboard: dashboardSchema },
177
178
  annotations: {
@@ -325,8 +326,16 @@ export function createTaskChefMcpServer({
325
326
  ...(logDashboardDiagnostic ? { log: logDashboardDiagnostic } : {}),
326
327
  });
327
328
  server.connect = async (...args) => {
328
- await originalConnect(...args);
329
- void autostartDashboard();
329
+ await autostartDashboard();
330
+ try {
331
+ await originalConnect(...args);
332
+ } catch (error) {
333
+ await Promise.allSettled([
334
+ Promise.resolve().then(() => dashboardManager.close()),
335
+ Promise.resolve().then(() => originalClose()),
336
+ ]);
337
+ throw error;
338
+ }
330
339
  };
331
340
 
332
341
  return server;
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 = "3";