taskchef 7.3.0 → 7.4.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.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +31 -1
- package/assets/taskchef-dispatcher-instructions.md +9 -0
- package/docs/spec.md +59 -3
- package/docs/workflows.md +44 -4
- package/index.js +9 -0
- package/mcp/server.js +12 -0
- package/package.json +1 -1
- package/skills/taskchef-bootstrap/SKILL.md +4 -2
- package/src/cli.js +20 -6
- package/src/dashboard-manager.js +183 -0
- package/src/dashboard.js +27 -0
- package/src/mcp.js +49 -1
- package/src/version.js +7 -0
package/README.md
CHANGED
|
@@ -33,7 +33,8 @@ npm install --global taskchef
|
|
|
33
33
|
|
|
34
34
|
The plugin provides four skills and a local MCP server. The npm installation
|
|
35
35
|
puts the `taskchef` CLI on `PATH`. TaskChef installs no hooks, schedules,
|
|
36
|
-
daemons, or background identity search
|
|
36
|
+
daemons, login items, system services, or background identity search and needs
|
|
37
|
+
no elevated permissions.
|
|
37
38
|
|
|
38
39
|
## Bootstrap and configure
|
|
39
40
|
|
|
@@ -93,6 +94,12 @@ reads the executor's own `CODEX_THREAD_ID`, self-links, and reports lifecycle
|
|
|
93
94
|
state. Independent outcomes may become separate executors; dependent work
|
|
94
95
|
should stay together.
|
|
95
96
|
|
|
97
|
+
At the start of every dispatcher turn, the managed workspace instructions ask
|
|
98
|
+
the MCP server to best-effort ensure the dashboard. A startup failure never
|
|
99
|
+
blocks an answer, report, or delegation. Every dispatcher response ends with
|
|
100
|
+
the stable [TaskChef Dashboard](http://127.0.0.1:3210/) link; a created-task
|
|
101
|
+
directive remains on the preceding line so dispatch still returns immediately.
|
|
102
|
+
|
|
96
103
|
For example, TaskChef generates this shape:
|
|
97
104
|
|
|
98
105
|
```text
|
|
@@ -146,12 +153,35 @@ an unambiguous eight-character prefix.
|
|
|
146
153
|
|
|
147
154
|
## Dashboard
|
|
148
155
|
|
|
156
|
+
Dispatcher turns call the input-free `ensure_dashboard` MCP tool. It starts at
|
|
157
|
+
most one dashboard inside the existing TaskChef MCP process on
|
|
158
|
+
`127.0.0.1:3210`, or reuses a listener only when its bounded `/api/health`
|
|
159
|
+
identity proves the exact TaskChef/dashboard-server version and the same
|
|
160
|
+
canonical workspace. The response says `started` or `reused` and includes the
|
|
161
|
+
stable URL, canonical workspace, and versions.
|
|
162
|
+
|
|
163
|
+
The in-process dashboard closes with the MCP process. Closing Codex or reloading
|
|
164
|
+
the plugin may therefore stop the dashboard; the next dispatcher turn restores
|
|
165
|
+
it. TaskChef adds no OS-persistent component.
|
|
166
|
+
|
|
167
|
+
For manual development, run the foreground CLI:
|
|
168
|
+
|
|
149
169
|
```sh
|
|
150
170
|
taskchef dashboard
|
|
171
|
+
taskchef dashboard --port 3211
|
|
151
172
|
```
|
|
152
173
|
|
|
153
174
|
The loopback dashboard watches `tasks.jsonl`, groups current states, and opens
|
|
154
175
|
linked Codex tasks. It does not mutate TaskChef data and prints its local URL.
|
|
176
|
+
When a compatible foreground dashboard already owns port 3210,
|
|
177
|
+
`ensure_dashboard` reuses it but does not take ownership. If an unknown,
|
|
178
|
+
different-workspace, or stale-version process owns the port, TaskChef reports a
|
|
179
|
+
concise conflict and never kills or replaces that process. The foreground CLI
|
|
180
|
+
similarly asks you to stop the listener or choose another `--port`.
|
|
181
|
+
|
|
182
|
+
The health endpoint contains only a fixed service marker, health schema,
|
|
183
|
+
TaskChef version, dashboard-server version, and canonical workspace. It exposes
|
|
184
|
+
no task data, credentials, environment variables, process control, or secrets.
|
|
155
185
|
|
|
156
186
|
## Common recovery
|
|
157
187
|
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
This folder is the canonical per-user TaskChef dispatcher workspace.
|
|
5
5
|
|
|
6
|
+
- Early in every dispatcher turn, best-effort call the TaskChef
|
|
7
|
+
`ensure_dashboard` MCP tool. Dashboard startup failure must not block direct
|
|
8
|
+
TaskChef answers, reporting, or delegation.
|
|
6
9
|
- Use `$taskchef-bootstrap` when initializing or refreshing this workspace,
|
|
7
10
|
changing or listing its configured projects, running TaskChef doctor,
|
|
8
11
|
or repairing its managed instructions.
|
|
@@ -20,4 +23,10 @@ This folder is the canonical per-user TaskChef dispatcher workspace.
|
|
|
20
23
|
write status or results to this workspace.
|
|
21
24
|
- Explicit invocations of TaskChef skills from other Codex projects use this
|
|
22
25
|
same workspace and task history through TaskChef's global resolution rules.
|
|
26
|
+
- Every final response in this dispatcher workspace must end with this exact
|
|
27
|
+
clickable link on its final non-empty line:
|
|
28
|
+
`[TaskChef Dashboard](http://127.0.0.1:3210/)`. Keep any
|
|
29
|
+
`::created-thread{...}` directive required by delegation on its own line
|
|
30
|
+
immediately before the dashboard link so the immediate-return contract still
|
|
31
|
+
holds. Include the link even when `ensure_dashboard` failed.
|
|
23
32
|
<!-- taskchef:dispatcher-instructions:end -->
|
package/docs/spec.md
CHANGED
|
@@ -39,8 +39,8 @@ is dated research, not contract.
|
|
|
39
39
|
privacy, and idempotency. It MUST NOT dispatch the owned assignment again.
|
|
40
40
|
- `taskchef-report` MUST own on-demand reporting. It MUST NOT poll or persist
|
|
41
41
|
inferred state.
|
|
42
|
-
- The MCP server MUST expose four primary lifecycle tools
|
|
43
|
-
`report_result` compatibility alias specified below.
|
|
42
|
+
- The MCP server MUST expose `ensure_dashboard`, four primary lifecycle tools,
|
|
43
|
+
and the deprecated `report_result` compatibility alias specified below.
|
|
44
44
|
- The CLI MAY administer and inspect the workspace, but MUST NOT provide a
|
|
45
45
|
second agent lifecycle protocol.
|
|
46
46
|
- The dashboard MUST be read-only with respect to dispatcher files.
|
|
@@ -100,6 +100,13 @@ fields MUST NOT change after recording.
|
|
|
100
100
|
|
|
101
101
|
## Required lifecycle
|
|
102
102
|
|
|
103
|
+
At the start of every dispatcher turn, the dispatcher SHOULD call
|
|
104
|
+
`ensure_dashboard` best-effort. Failure MUST NOT block direct TaskChef answers,
|
|
105
|
+
reporting, or delegation. Every dispatcher final response MUST end with the
|
|
106
|
+
exact clickable `[TaskChef Dashboard](http://127.0.0.1:3210/)` link even when
|
|
107
|
+
ensure failed. A created-thread directive MUST remain on its own line before
|
|
108
|
+
the final link, preserving the delegate skill's immediate-return contract.
|
|
109
|
+
|
|
103
110
|
1. The dispatcher MUST call `prepare_dispatch` once per outcome.
|
|
104
111
|
2. It MUST choose exactly one configured project and exact native-project path.
|
|
105
112
|
3. It MUST build the instruction with the user's outcome beginning on line 1
|
|
@@ -144,6 +151,43 @@ path. Success returns both one text content item and the stated structured
|
|
|
144
151
|
object. Validation, marker, identity, uniqueness, freshness, or filesystem
|
|
145
152
|
failures are surfaced as tool errors and MUST NOT partially mutate the log.
|
|
146
153
|
|
|
154
|
+
### `ensure_dashboard`
|
|
155
|
+
|
|
156
|
+
**Caller:** dispatcher. **Mutation:** starts at most one in-process loopback
|
|
157
|
+
HTTP server; it does not mutate dispatcher workspace files.
|
|
158
|
+
|
|
159
|
+
**Input:** empty object.
|
|
160
|
+
|
|
161
|
+
**Structured output:**
|
|
162
|
+
|
|
163
|
+
```text
|
|
164
|
+
{ dashboard: {
|
|
165
|
+
action: "started" | "reused",
|
|
166
|
+
url: "http://127.0.0.1:3210/",
|
|
167
|
+
workspace: string,
|
|
168
|
+
taskchefVersion: string,
|
|
169
|
+
serverVersion: string
|
|
170
|
+
} }
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Calls MUST serialize within one MCP process. The first call starts an owned
|
|
174
|
+
dashboard or reuses an exact compatible listener; later and concurrent calls
|
|
175
|
+
are idempotent and report reuse after the single start. The stable default MUST
|
|
176
|
+
bind only to `127.0.0.1:3210` and MUST NOT accept a model-supplied workspace,
|
|
177
|
+
host, or port.
|
|
178
|
+
|
|
179
|
+
Before reuse, TaskChef MUST query a bounded loopback identity endpoint and
|
|
180
|
+
require the exact fixed service/schema, TaskChef version, dashboard-server
|
|
181
|
+
version, and canonical workspace. An unknown, malformed, different-workspace,
|
|
182
|
+
or stale-version listener MUST produce a concise actionable conflict. TaskChef
|
|
183
|
+
MUST NOT kill, replace, signal, or otherwise control that listener. A startup
|
|
184
|
+
failure MUST leave no owned listener. The MCP server MUST close its owned
|
|
185
|
+
dashboard when its transport or process shuts down; it MUST NOT close a reused
|
|
186
|
+
external foreground server.
|
|
187
|
+
|
|
188
|
+
**Annotations:** `readOnlyHint: false`, `destructiveHint: false`,
|
|
189
|
+
`openWorldHint: false`.
|
|
190
|
+
|
|
147
191
|
### `prepare_dispatch`
|
|
148
192
|
|
|
149
193
|
**Caller:** dispatcher. **Mutation:** none.
|
|
@@ -259,7 +303,13 @@ reports MAY read a selected task once when metadata is newer or evidence is
|
|
|
259
303
|
uncertain. Reports MUST NOT poll or classify assistant prose.
|
|
260
304
|
|
|
261
305
|
The dashboard MUST bind only to loopback, validate the current workspace
|
|
262
|
-
snapshot, and avoid sessions or shared client state.
|
|
306
|
+
snapshot, and avoid sessions or shared client state. `GET /api/health` MUST
|
|
307
|
+
return only the bounded service identity, health schema, exact TaskChef and
|
|
308
|
+
dashboard-server versions, and canonical workspace. It MUST NOT return task
|
|
309
|
+
records, secrets, credentials, environment values, or process-control data.
|
|
310
|
+
Identity remains available while an already-started monitor retains its last
|
|
311
|
+
valid snapshot after a later invalid task log; an invalid initial log MAY fail
|
|
312
|
+
startup safely. Direct thread navigation
|
|
263
313
|
MUST require a canonical Codex UUIDv7. Otherwise it MAY open the revalidated
|
|
264
314
|
configured project. Project paths from task history MUST be matched against
|
|
265
315
|
current configuration before use.
|
|
@@ -279,3 +329,9 @@ loopback origin, and current configuration before acting.
|
|
|
279
329
|
TaskChef MUST NOT use lifecycle hooks, schedules, polling, recent-thread search,
|
|
280
330
|
transcript search, title matching, hidden reasoning, or token usage to discover
|
|
281
331
|
identity or infer semantic results.
|
|
332
|
+
|
|
333
|
+
TaskChef MUST NOT install or require daemons, launchd agents, login items,
|
|
334
|
+
system services, cron jobs, hooks, privileged components, or elevated/system
|
|
335
|
+
permissions for dashboard availability. Availability is best-effort while the
|
|
336
|
+
owning Codex/plugin MCP process is alive and is not guaranteed while Codex is
|
|
337
|
+
closed.
|
package/docs/workflows.md
CHANGED
|
@@ -14,16 +14,55 @@ research.
|
|
|
14
14
|
| `skills/taskchef-executor/SKILL.md` | Own, self-link, execute, and report every executor turn. |
|
|
15
15
|
| `skills/taskchef-bootstrap/SKILL.md` | Initialize current workspace and configure projects. |
|
|
16
16
|
| `skills/taskchef-report/SKILL.md` | Select cached tasks and perform bounded live checks. |
|
|
17
|
-
| `src/mcp.js` |
|
|
17
|
+
| `src/mcp.js` | Dashboard ensure, four primary lifecycle tools, one deprecated alias, shutdown ownership, and MCP annotations. |
|
|
18
18
|
| `src/delegation.js` | UUID marker, concise executor-skill invocation shape, and creation-failure handling. |
|
|
19
19
|
| `src/workspace.js` | Current schemas, validation, locking, atomic JSONL writes, linking, and result freshness. |
|
|
20
20
|
| `src/cli.js` | Administration, inspection, diagnostics, and dashboard startup. |
|
|
21
|
-
| `src/dashboard.js` |
|
|
21
|
+
| `src/dashboard.js` | Versioned health identity, validated snapshots, SSE fan-out, and bounded open actions. |
|
|
22
|
+
| `src/dashboard-manager.js` | Concurrent singleton ensure, exact listener reuse, conflicts, and owned shutdown. |
|
|
22
23
|
|
|
23
24
|
The MCP process resolves `TASKCHEF_WORKSPACE` once and never accepts a model
|
|
24
25
|
supplied path. The CLI resolves `--workspace`, then the environment, then the
|
|
25
26
|
per-user default.
|
|
26
27
|
|
|
28
|
+
## Dispatcher dashboard lifecycle
|
|
29
|
+
|
|
30
|
+
The generated managed `AGENTS.md` block makes dashboard maintenance a
|
|
31
|
+
best-effort prelude to every dispatcher turn and keeps response ordering
|
|
32
|
+
centralized instead of duplicating it across delegate/report skills.
|
|
33
|
+
|
|
34
|
+
```mermaid
|
|
35
|
+
sequenceDiagram
|
|
36
|
+
autonumber
|
|
37
|
+
participant D as Dispatcher
|
|
38
|
+
participant M as TaskChef MCP
|
|
39
|
+
participant H as Loopback health
|
|
40
|
+
participant S as Dashboard server
|
|
41
|
+
D->>M: ensure_dashboard()
|
|
42
|
+
M->>M: Serialize concurrent ensure calls
|
|
43
|
+
M->>H: GET 127.0.0.1:3210/api/health
|
|
44
|
+
alt Exact service, versions, and canonical workspace
|
|
45
|
+
H-->>M: Bounded compatible identity
|
|
46
|
+
M-->>D: reused, URL, workspace, versions
|
|
47
|
+
else No listener
|
|
48
|
+
H--xM: Connection refused
|
|
49
|
+
M->>S: Start in this MCP process on 127.0.0.1:3210
|
|
50
|
+
S-->>M: Owned server
|
|
51
|
+
M-->>D: started, URL, workspace, versions
|
|
52
|
+
else Unknown, stale, or different workspace
|
|
53
|
+
H-->>M: Missing or incompatible identity
|
|
54
|
+
M-->>D: Actionable conflict, listener untouched
|
|
55
|
+
end
|
|
56
|
+
Note over D: Continue even when ensure failed
|
|
57
|
+
D-->>D: Answer, report, or dispatch
|
|
58
|
+
Note over D: Created-thread directive, when any, precedes final dashboard link
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
When the MCP transport or plugin process closes, it closes only the server it
|
|
62
|
+
started. A compatible foreground `taskchef dashboard` listener may be reused
|
|
63
|
+
but remains owned by that CLI process. No TaskChef path terminates an unknown
|
|
64
|
+
listener or installs OS persistence.
|
|
65
|
+
|
|
27
66
|
## Normal delegation and self-linking
|
|
28
67
|
|
|
29
68
|
The dispatcher uses native Codex project discovery for routing and MCP for
|
|
@@ -235,8 +274,9 @@ sequenceDiagram
|
|
|
235
274
|
```
|
|
236
275
|
|
|
237
276
|
The dashboard binds to `127.0.0.1`, has no shared session state, limits
|
|
238
|
-
request bodies, and checks origin/authority for stateful local actions.
|
|
239
|
-
|
|
277
|
+
request bodies, and checks origin/authority for stateful local actions. Its
|
|
278
|
+
bounded identity endpoint contains no task data or secrets. Historical project
|
|
279
|
+
paths are untrusted until matched against current configuration.
|
|
240
280
|
|
|
241
281
|
## Concurrency and trust boundaries
|
|
242
282
|
|
package/index.js
CHANGED
|
@@ -57,10 +57,19 @@ export {
|
|
|
57
57
|
} from "./src/codex-app.js";
|
|
58
58
|
|
|
59
59
|
export {
|
|
60
|
+
DASHBOARD_HEALTH_MAX_BYTES,
|
|
61
|
+
DASHBOARD_HEALTH_PATH,
|
|
60
62
|
DashboardMonitor,
|
|
61
63
|
createDashboardServer,
|
|
62
64
|
dashboardAuthority,
|
|
63
65
|
sortTasksByMeaningfulUpdate,
|
|
64
66
|
} from "./src/dashboard.js";
|
|
65
67
|
|
|
68
|
+
export {
|
|
69
|
+
createDashboardManager,
|
|
70
|
+
readDashboardIdentity,
|
|
71
|
+
} from "./src/dashboard-manager.js";
|
|
72
|
+
|
|
73
|
+
export { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./src/version.js";
|
|
74
|
+
|
|
66
75
|
export { createTaskChefMcpServer } from "./src/mcp.js";
|
package/mcp/server.js
CHANGED
|
@@ -5,3 +5,15 @@ import { createTaskChefMcpServer } from "../src/mcp.js";
|
|
|
5
5
|
|
|
6
6
|
const server = createTaskChefMcpServer();
|
|
7
7
|
await server.connect(new StdioServerTransport());
|
|
8
|
+
|
|
9
|
+
let shutdownPromise = null;
|
|
10
|
+
const shutdown = () => {
|
|
11
|
+
shutdownPromise ??= server.close().catch((error) => {
|
|
12
|
+
process.stderr.write(`TaskChef MCP shutdown failed: ${error.message}\n`);
|
|
13
|
+
process.exitCode = 1;
|
|
14
|
+
});
|
|
15
|
+
return shutdownPromise;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
process.once("SIGINT", shutdown);
|
|
19
|
+
process.once("SIGTERM", shutdown);
|
package/package.json
CHANGED
|
@@ -19,8 +19,10 @@ all deterministic workspace operations.
|
|
|
19
19
|
dispatcher workspace. Preserve unrelated user-owned paths.
|
|
20
20
|
- Do not dispatch tasks or report on executor threads during bootstrap unless
|
|
21
21
|
the user separately requests those actions.
|
|
22
|
-
- Never create hooks, schedules, polling,
|
|
23
|
-
|
|
22
|
+
- Never create hooks, schedules, polling, daemons, login items, or system
|
|
23
|
+
services. The managed dispatcher instructions own best-effort dashboard
|
|
24
|
+
startup and final-link guidance; bootstrap only refreshes that managed block.
|
|
25
|
+
TaskChef executors self-link through the installed MCP server.
|
|
24
26
|
|
|
25
27
|
## Initialize and repair
|
|
26
28
|
|
package/src/cli.js
CHANGED
|
@@ -409,10 +409,22 @@ async function dashboard(args) {
|
|
|
409
409
|
values: ["--port", "--workspace"],
|
|
410
410
|
switches: ["--json"],
|
|
411
411
|
});
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
412
|
+
const port = dashboardPort(args);
|
|
413
|
+
let server;
|
|
414
|
+
try {
|
|
415
|
+
server = await createDashboardServer({
|
|
416
|
+
workspace: workspaceRoot(args),
|
|
417
|
+
port,
|
|
418
|
+
});
|
|
419
|
+
} catch (error) {
|
|
420
|
+
if (error?.code === "EADDRINUSE") {
|
|
421
|
+
throw new Error(
|
|
422
|
+
`dashboard port 127.0.0.1:${port} is already in use; `
|
|
423
|
+
+ "stop the existing listener or choose another --port (TaskChef will not terminate it)",
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
416
428
|
print({
|
|
417
429
|
schemaVersion: 1,
|
|
418
430
|
url: server.url,
|
|
@@ -461,8 +473,10 @@ Project import reads a JSON
|
|
|
461
473
|
array from a file, or from standard input when the source is '-' or omitted.
|
|
462
474
|
Workspace resolution precedence is --workspace, TASKCHEF_WORKSPACE, then
|
|
463
475
|
~/.agents/taskchef.
|
|
464
|
-
The dashboard binds to 127.0.0.1 and reads the canonical task log
|
|
465
|
-
modifying dispatcher-workspace files.
|
|
476
|
+
The foreground dashboard binds to 127.0.0.1 and reads the canonical task log
|
|
477
|
+
without modifying dispatcher-workspace files. The dispatcher MCP may reuse a
|
|
478
|
+
compatible foreground server on port 3210; neither mode terminates a listener
|
|
479
|
+
that already occupies its requested port.
|
|
466
480
|
`);
|
|
467
481
|
}
|
|
468
482
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DASHBOARD_HEALTH_MAX_BYTES,
|
|
7
|
+
DASHBOARD_HEALTH_PATH,
|
|
8
|
+
createDashboardServer,
|
|
9
|
+
dashboardAuthority,
|
|
10
|
+
} from "./dashboard.js";
|
|
11
|
+
import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
14
|
+
const DEFAULT_PORT = 3210;
|
|
15
|
+
const HEALTH_TIMEOUT_MS = 750;
|
|
16
|
+
|
|
17
|
+
function expectedIdentity(workspace, taskchefVersion, serverVersion) {
|
|
18
|
+
return {
|
|
19
|
+
schemaVersion: 1,
|
|
20
|
+
service: "taskchef-dashboard",
|
|
21
|
+
taskchefVersion,
|
|
22
|
+
serverVersion,
|
|
23
|
+
workspace,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isExactIdentity(value, expected) {
|
|
28
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
29
|
+
const keys = Object.keys(value).sort();
|
|
30
|
+
const expectedKeys = Object.keys(expected).sort();
|
|
31
|
+
return keys.length === expectedKeys.length
|
|
32
|
+
&& keys.every((key, index) => key === expectedKeys[index])
|
|
33
|
+
&& expectedKeys.every((key) => value[key] === expected[key]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function listenerConflict(url, detail) {
|
|
37
|
+
return new Error(
|
|
38
|
+
`TaskChef dashboard port conflict at ${url} ${detail} `
|
|
39
|
+
+ "Stop that listener or choose another port for the foreground dashboard CLI; TaskChef will not terminate it.",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function readDashboardIdentity({
|
|
44
|
+
host = DEFAULT_HOST,
|
|
45
|
+
port = DEFAULT_PORT,
|
|
46
|
+
maximumBytes = DASHBOARD_HEALTH_MAX_BYTES,
|
|
47
|
+
timeoutMs = HEALTH_TIMEOUT_MS,
|
|
48
|
+
} = {}) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
let settled = false;
|
|
51
|
+
const finish = (error, value) => {
|
|
52
|
+
if (settled) return;
|
|
53
|
+
settled = true;
|
|
54
|
+
clearTimeout(deadline);
|
|
55
|
+
if (error) reject(error);
|
|
56
|
+
else resolve(value);
|
|
57
|
+
};
|
|
58
|
+
const request = http.get({
|
|
59
|
+
host,
|
|
60
|
+
port,
|
|
61
|
+
path: DASHBOARD_HEALTH_PATH,
|
|
62
|
+
headers: {
|
|
63
|
+
Accept: "application/json",
|
|
64
|
+
Host: dashboardAuthority(host, port),
|
|
65
|
+
},
|
|
66
|
+
}, (response) => {
|
|
67
|
+
const chunks = [];
|
|
68
|
+
let total = 0;
|
|
69
|
+
response.on("data", (chunk) => {
|
|
70
|
+
total += chunk.length;
|
|
71
|
+
if (total > maximumBytes) {
|
|
72
|
+
const error = new Error("dashboard health response exceeds the identity limit");
|
|
73
|
+
finish(error);
|
|
74
|
+
request.destroy(error);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
chunks.push(chunk);
|
|
78
|
+
});
|
|
79
|
+
response.on("end", () => {
|
|
80
|
+
if (response.statusCode !== 200) {
|
|
81
|
+
finish(new Error(`dashboard health returned HTTP ${response.statusCode}`));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
finish(null, JSON.parse(Buffer.concat(chunks, total).toString("utf8")));
|
|
86
|
+
} catch {
|
|
87
|
+
finish(new Error("dashboard health returned invalid JSON"));
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
const deadline = setTimeout(() => {
|
|
92
|
+
const error = new Error("dashboard health request timed out");
|
|
93
|
+
finish(error);
|
|
94
|
+
request.destroy(error);
|
|
95
|
+
}, timeoutMs);
|
|
96
|
+
request.on("error", (error) => finish(error));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function listenerAbsent(error) {
|
|
101
|
+
return error?.code === "ECONNREFUSED" || error?.code === "EHOSTUNREACH";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function createDashboardManager({
|
|
105
|
+
workspace,
|
|
106
|
+
host = DEFAULT_HOST,
|
|
107
|
+
port = DEFAULT_PORT,
|
|
108
|
+
taskchefVersion = TASKCHEF_VERSION,
|
|
109
|
+
serverVersion = DASHBOARD_SERVER_VERSION,
|
|
110
|
+
createServer = createDashboardServer,
|
|
111
|
+
readIdentity = readDashboardIdentity,
|
|
112
|
+
} = {}) {
|
|
113
|
+
let canonicalWorkspace;
|
|
114
|
+
let ownedServer = null;
|
|
115
|
+
let ensurePromise = null;
|
|
116
|
+
let closePromise = null;
|
|
117
|
+
|
|
118
|
+
const publicResult = (action) => ({
|
|
119
|
+
action,
|
|
120
|
+
url: `http://${dashboardAuthority(host, ownedServer?.port ?? port)}/`,
|
|
121
|
+
workspace: canonicalWorkspace,
|
|
122
|
+
taskchefVersion,
|
|
123
|
+
serverVersion,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const probe = async () => {
|
|
127
|
+
const url = `http://${dashboardAuthority(host, port)}/`;
|
|
128
|
+
let identity;
|
|
129
|
+
try {
|
|
130
|
+
identity = await readIdentity({ host, port });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (listenerAbsent(error)) return false;
|
|
133
|
+
throw listenerConflict(url, `is occupied but did not return a compatible identity (${error.message}).`);
|
|
134
|
+
}
|
|
135
|
+
const expected = expectedIdentity(canonicalWorkspace, taskchefVersion, serverVersion);
|
|
136
|
+
if (!isExactIdentity(identity, expected)) {
|
|
137
|
+
throw listenerConflict(url, "belongs to an unknown, stale, or different-workspace service.");
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const ensureOnce = async () => {
|
|
143
|
+
canonicalWorkspace ??= await realpath(path.resolve(workspace));
|
|
144
|
+
if (ownedServer) return publicResult("reused");
|
|
145
|
+
if (await probe()) return publicResult("reused");
|
|
146
|
+
try {
|
|
147
|
+
ownedServer = await createServer({
|
|
148
|
+
workspace: canonicalWorkspace,
|
|
149
|
+
host,
|
|
150
|
+
port,
|
|
151
|
+
taskchefVersion,
|
|
152
|
+
serverVersion,
|
|
153
|
+
});
|
|
154
|
+
return publicResult("started");
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (error?.code !== "EADDRINUSE") throw error;
|
|
157
|
+
if (await probe()) return publicResult("reused");
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
async ensure() {
|
|
164
|
+
if (closePromise) throw new Error("TaskChef dashboard manager is shutting down");
|
|
165
|
+
if (ensurePromise) {
|
|
166
|
+
await ensurePromise;
|
|
167
|
+
return publicResult("reused");
|
|
168
|
+
}
|
|
169
|
+
ensurePromise = ensureOnce().finally(() => { ensurePromise = null; });
|
|
170
|
+
return ensurePromise;
|
|
171
|
+
},
|
|
172
|
+
async close() {
|
|
173
|
+
closePromise ??= (async () => {
|
|
174
|
+
await ensurePromise?.catch(() => {});
|
|
175
|
+
const server = ownedServer;
|
|
176
|
+
ownedServer = null;
|
|
177
|
+
await server?.close();
|
|
178
|
+
})();
|
|
179
|
+
return closePromise;
|
|
180
|
+
},
|
|
181
|
+
get owned() { return ownedServer !== null; },
|
|
182
|
+
};
|
|
183
|
+
}
|
package/src/dashboard.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
parseTaskLogContent,
|
|
17
17
|
readConfig,
|
|
18
18
|
} from "./workspace.js";
|
|
19
|
+
import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
|
|
19
20
|
|
|
20
21
|
const TASKS_FILE_NAME = "tasks.jsonl";
|
|
21
22
|
const STATIC_ROOT = fileURLToPath(new URL("./dashboard/", import.meta.url));
|
|
@@ -24,6 +25,8 @@ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1"]);
|
|
|
24
25
|
const DEFAULT_MAX_FILE_BYTES = 16 * 1024 * 1024;
|
|
25
26
|
const DEFAULT_MAX_TASKS = 2_000;
|
|
26
27
|
const DEFAULT_MAX_EVENT_CLIENTS = 16;
|
|
28
|
+
export const DASHBOARD_HEALTH_PATH = "/api/health";
|
|
29
|
+
export const DASHBOARD_HEALTH_MAX_BYTES = 8 * 1024;
|
|
27
30
|
const CONTENT_SECURITY_POLICY = [
|
|
28
31
|
"default-src 'self'",
|
|
29
32
|
"base-uri 'none'",
|
|
@@ -402,6 +405,8 @@ export async function createDashboardServer({
|
|
|
402
405
|
monitorOptions = {},
|
|
403
406
|
openProject = null,
|
|
404
407
|
openThread = null,
|
|
408
|
+
taskchefVersion = TASKCHEF_VERSION,
|
|
409
|
+
serverVersion = DASHBOARD_SERVER_VERSION,
|
|
405
410
|
} = {}) {
|
|
406
411
|
if (!LOOPBACK_HOSTS.has(host)) {
|
|
407
412
|
throw new Error("dashboard host must be a loopback address");
|
|
@@ -414,6 +419,17 @@ export async function createDashboardServer({
|
|
|
414
419
|
}
|
|
415
420
|
const monitor = new DashboardMonitor(workspace, monitorOptions);
|
|
416
421
|
await monitor.start();
|
|
422
|
+
const identity = Object.freeze({
|
|
423
|
+
schemaVersion: 1,
|
|
424
|
+
service: "taskchef-dashboard",
|
|
425
|
+
taskchefVersion,
|
|
426
|
+
serverVersion,
|
|
427
|
+
workspace: monitor.workspace,
|
|
428
|
+
});
|
|
429
|
+
if (Buffer.byteLength(`${JSON.stringify(identity)}\n`) > DASHBOARD_HEALTH_MAX_BYTES) {
|
|
430
|
+
monitor.close();
|
|
431
|
+
throw new Error("dashboard identity exceeds the health response limit");
|
|
432
|
+
}
|
|
417
433
|
const clients = new Set();
|
|
418
434
|
let allowedAuthority;
|
|
419
435
|
let allowedOrigin;
|
|
@@ -446,6 +462,16 @@ export async function createDashboardServer({
|
|
|
446
462
|
return;
|
|
447
463
|
}
|
|
448
464
|
|
|
465
|
+
if (url.pathname === DASHBOARD_HEALTH_PATH && (method === "GET" || method === "HEAD")) {
|
|
466
|
+
if (method === "HEAD") {
|
|
467
|
+
response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
|
|
468
|
+
response.end();
|
|
469
|
+
} else {
|
|
470
|
+
sendJson(response, 200, identity);
|
|
471
|
+
}
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
449
475
|
if (url.pathname === "/api/snapshot" && (method === "GET" || method === "HEAD")) {
|
|
450
476
|
if (method === "HEAD") {
|
|
451
477
|
response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
|
|
@@ -574,6 +600,7 @@ export async function createDashboardServer({
|
|
|
574
600
|
port: boundPort,
|
|
575
601
|
origin: allowedOrigin,
|
|
576
602
|
url: `${allowedOrigin}/`,
|
|
603
|
+
identity,
|
|
577
604
|
monitor,
|
|
578
605
|
get eventClientCount() { return clients.size; },
|
|
579
606
|
async close() {
|
package/src/mcp.js
CHANGED
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
reportTaskResult,
|
|
9
9
|
} from "./workspace.js";
|
|
10
10
|
import { parseTaskChefMarker } from "./delegation.js";
|
|
11
|
+
import { createDashboardManager } from "./dashboard-manager.js";
|
|
11
12
|
import { resolveWorkspacePath } from "./workspace-path.js";
|
|
13
|
+
import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
|
|
12
14
|
|
|
13
15
|
const projectSchema = z.object({
|
|
14
16
|
name: z.string(),
|
|
@@ -49,6 +51,14 @@ const preparationSchema = z.object({
|
|
|
49
51
|
projects: z.array(projectSchema),
|
|
50
52
|
});
|
|
51
53
|
|
|
54
|
+
const dashboardSchema = z.object({
|
|
55
|
+
action: z.enum(["started", "reused"]),
|
|
56
|
+
url: z.string().url(),
|
|
57
|
+
workspace: z.string(),
|
|
58
|
+
taskchefVersion: z.string(),
|
|
59
|
+
serverVersion: z.string(),
|
|
60
|
+
});
|
|
61
|
+
|
|
52
62
|
function toolResult(key, value, message) {
|
|
53
63
|
return {
|
|
54
64
|
structuredContent: { [key]: value },
|
|
@@ -63,15 +73,53 @@ export function createTaskChefMcpServer({
|
|
|
63
73
|
reportResult = reportTaskResult,
|
|
64
74
|
reportState = reportTaskState,
|
|
65
75
|
link = linkTask,
|
|
76
|
+
dashboardManager = createDashboardManager({ workspace }),
|
|
66
77
|
} = {}) {
|
|
67
78
|
const server = new McpServer(
|
|
68
|
-
{ name: "taskchef", version:
|
|
79
|
+
{ name: "taskchef", version: TASKCHEF_VERSION },
|
|
69
80
|
{
|
|
70
81
|
instructions:
|
|
71
82
|
"Prepare with prepare_dispatch, call record_task before creating the Codex task, then create it natively and return immediately. Follow the active TaskChef skill for role-specific sequencing of the identity and state tools.",
|
|
72
83
|
},
|
|
73
84
|
);
|
|
74
85
|
|
|
86
|
+
const originalClose = server.close.bind(server);
|
|
87
|
+
let closePromise = null;
|
|
88
|
+
server.close = async () => {
|
|
89
|
+
closePromise ??= (async () => {
|
|
90
|
+
await dashboardManager.close();
|
|
91
|
+
await originalClose();
|
|
92
|
+
})();
|
|
93
|
+
return closePromise;
|
|
94
|
+
};
|
|
95
|
+
server.server.onclose = () => {
|
|
96
|
+
void dashboardManager.close();
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
server.registerTool(
|
|
100
|
+
"ensure_dashboard",
|
|
101
|
+
{
|
|
102
|
+
title: "Ensure TaskChef dashboard",
|
|
103
|
+
description:
|
|
104
|
+
"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.",
|
|
105
|
+
inputSchema: {},
|
|
106
|
+
outputSchema: { dashboard: dashboardSchema },
|
|
107
|
+
annotations: {
|
|
108
|
+
readOnlyHint: false,
|
|
109
|
+
destructiveHint: false,
|
|
110
|
+
openWorldHint: false,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
async () => {
|
|
114
|
+
const dashboard = await dashboardManager.ensure();
|
|
115
|
+
return toolResult(
|
|
116
|
+
"dashboard",
|
|
117
|
+
dashboard,
|
|
118
|
+
`${dashboard.action === "started" ? "Started" : "Reused"} TaskChef dashboard ${dashboard.url}`,
|
|
119
|
+
);
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
|
|
75
123
|
server.registerTool(
|
|
76
124
|
"prepare_dispatch",
|
|
77
125
|
{
|
package/src/version.js
ADDED