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.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +24 -10
- package/docs/spec.md +28 -11
- package/docs/workflows.md +11 -10
- package/package.json +1 -1
- package/src/codex-app.js +87 -9
- package/src/dashboard/actions.js +38 -0
- package/src/dashboard/app.js +24 -1
- package/src/dashboard/index.html +1 -0
- package/src/dashboard/state.js +7 -0
- package/src/dashboard/styles.css +3 -2
- package/src/dashboard-manager.js +10 -3
- package/src/dashboard.js +83 -0
- package/src/mcp.js +13 -4
- package/src/version.js +1 -1
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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,
|
|
287
|
-
no task data, credentials, environment variables, process control,
|
|
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
|

|
|
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.
|
|
354
|
-
servers may be reused; unknown listeners
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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,
|
|
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
|
|
251
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
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`,
|
|
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;
|
|
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
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
|
|
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, {
|
|
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,
|
|
34
|
+
.map((directory) => path.join(directory, platform === "win32" ? "codex.exe" : "codex"));
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
function isDesktopBundleCandidate(filePath) {
|
|
31
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/dashboard/actions.js
CHANGED
|
@@ -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
|
+
}
|
package/src/dashboard/app.js
CHANGED
|
@@ -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
|
+
});
|
package/src/dashboard/index.html
CHANGED
|
@@ -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>
|
package/src/dashboard/state.js
CHANGED
|
@@ -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;
|
package/src/dashboard/styles.css
CHANGED
|
@@ -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
|
-
.
|
|
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-manager.js
CHANGED
|
@@ -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(
|
|
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
|
|
329
|
-
|
|
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