taskchef 9.1.0 → 9.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "9.1.0",
3
+ "version": "9.3.0",
4
4
  "description": "Native Codex dashboard with lightweight project delegation.",
5
5
  "author": {
6
6
  "name": "Favo Yang",
package/README.md CHANGED
@@ -6,9 +6,33 @@ Chats appear regardless of how they were started.
6
6
 
7
7
  ## Install
8
8
 
9
- Install **TaskChef** from the `favoyang-plugins` Codex marketplace. TaskChef uses
10
- a local Node.js MCP server; Node.js 22.18+, 23.2+, or 24+ is required. Open **TaskChef**
11
- from the Codex sidebar. No dispatcher workspace or project indexing is needed.
9
+ TaskChef's marketplace lives in this repository. In Codex, open **Settings →
10
+ Plugins → Add → Add a marketplace** and use
11
+ `https://github.com/favoyang/taskchef.git`. Leave the Git ref as `main` and Sparse
12
+ paths empty. Open **Plugins → Personal**, find **TaskChef** under **TaskChef**,
13
+ and install it.
14
+
15
+ Alternatively, use the Codex CLI:
16
+
17
+ ```sh
18
+ codex plugin marketplace add favoyang/taskchef
19
+ codex plugin add taskchef@taskchef
20
+ ```
21
+
22
+ Restart Codex. Open **…** below Plugins in the left dock, find **TaskChef**, and
23
+ pin it to keep the board in the sidebar. TaskChef uses a local Node.js MCP
24
+ server and npm. Supported Node.js versions are 22.18+ in the 22 series, or
25
+ 23.2 and newer. Node.js 24 is recommended for a new installation. Server
26
+ dependencies are included in the published package.
27
+
28
+ ### Move from the shared marketplace
29
+
30
+ If you installed `taskchef@favoyang-plugins`, add the new marketplace and install
31
+ `taskchef@taskchef` with the commands above. After it installs successfully,
32
+ remove the old plugin with `codex plugin remove taskchef@favoyang-plugins`, then
33
+ restart Codex. Keep only one released TaskChef installation enabled. Local
34
+ settings, GitHub credentials, and Codex chat history are independent of the
35
+ marketplace cache and do not need to be reset. TaskChef Dev stays separate.
12
36
 
13
37
  Codex databases and logs stay on your computer and remain the source of truth.
14
38
  TaskChef keeps its own settings, manual Done marks, notification state, and
@@ -48,3 +72,8 @@ name; apply that identity only to its local staging package. See
48
72
  [development packaging](docs/mcp-app.md#development-plugin-identity).
49
73
 
50
74
  [Native app guide](docs/mcp-app.md) · [GitHub setup](docs/taskchef-next-github.md)
75
+
76
+ ## Feedback
77
+
78
+ [Report a bug](https://github.com/favoyang/taskchef/issues/new?template=bug_report.yml)
79
+ or [request a feature](https://github.com/favoyang/taskchef/issues/new?template=feature_request.yml).
package/docs/mcp-app.md CHANGED
@@ -304,3 +304,64 @@ update window, and search filters. It starts off when the dashboard opens. Pin
304
304
  changes appear on the next data refresh. The current desktop pin list takes
305
305
  precedence over the database `is_pinned` field. TaskChef ignores old migration
306
306
  snapshots and never writes pin state. Without either source, chats are unpinned.
307
+
308
+ ## Live approval requests
309
+
310
+ TaskChef optionally subscribes to Running chats through the already-running
311
+ Codex desktop app's private IPC socket (`$CODEX_HOME/ipc/ipc.sock`, or the
312
+ `codex-ipc` named pipe on Windows). It sends only initialization and local-chat
313
+ subscription messages; it never starts a turn or sends an approval decision.
314
+ This is a private compatibility surface, observed with Codex 26.1007.30912,
315
+ state-stream version 11 and subscription version 1.
316
+
317
+ An active `waitingOnApproval` flag places the chat in Waiting with a **Needs
318
+ approval** cover. Details show the pending request, its source, and its origin
319
+ when available. **Open chat to approve** takes the user to Codex to inspect and
320
+ allow or deny the request. This is separate from the asynchronous questions
321
+ panel. The saved turn can remain `inProgress` throughout the approval wait.
322
+ Approval waits cannot be marked Done or acknowledged as Scheduled.
323
+
324
+ The bridge keeps only runtime status and pending requests in backend memory.
325
+ TaskChef responses contain only bounded display fields, never raw conversation
326
+ snapshots. Requests must match the chat and selected turn. Missed patch
327
+ revisions trigger a fresh snapshot. Disconnects clear live evidence, and
328
+ subsequent refreshes retry at most once every 15 seconds. Subscriptions end when
329
+ the database turn finishes or the chat is archived, and on MCP shutdown.
330
+
331
+ If the socket is unavailable, disconnects, or its protocol changes, TaskChef
332
+ falls back to saved database state. The notification center reports the first
333
+ IPC failure once per installed TaskChef version, shared across views and MCP
334
+ processes. The watermark lives in `notifications.json.errors.json`, so the released
335
+ plugin’s older notification writer cannot erase it. Reading or clearing that
336
+ message does not cause polling to replay it. Other features continue working. The first connection waits at most one
337
+ second, and missing chat snapshots wait at most 250 ms for all subscriptions
338
+ combined. Hidden views do not initiate refreshes; an existing subscription can
339
+ still receive live updates while the MCP process remains running.
340
+
341
+ ## Marketplace and release ownership
342
+
343
+ The public catalog is `.agents/plugins/marketplace.json` in this repository.
344
+ Its marketplace name is `taskchef`; the released install is `taskchef@taskchef`.
345
+ The catalog pins a published npm package containing the built UI and production
346
+ server dependencies. A raw Git checkout is not a packaged release.
347
+
348
+ After semantic-release publishes, the marketplace job checks the expected npm
349
+ version and archive, then commits only the catalog pin on the latest `main`.
350
+ A failed check leaves the prior pin in place and fails the workflow. The job
351
+ uses this repository's GitHub token; no shared-marketplace deploy key is needed.
352
+ If the push loses a race with another main update, rerun the Release workflow
353
+ on main to retry after checking the current published version. Do not force push.
354
+
355
+ Keep TaskChef Dev in a separate local marketplace named `taskchef-next-preview`,
356
+ with plugin ID `taskchef-next` and display name **TaskChef Dev**. Store that
357
+ marketplace and its staging packages in a persistent local folder, outside
358
+ this public checkout. Its catalog uses `source.source: local` with a path to
359
+ the prepared staging package. Add that folder with
360
+ `codex plugin marketplace add /path/to/local-dev-marketplace`, then install
361
+ `codex plugin add taskchef-next@taskchef-next-preview`. This can coexist with
362
+ `taskchef@taskchef`. Never rename the source release manifest for Dev.
363
+
364
+ Retire the old shared catalog entry only after this repository's catalog is
365
+ live, the release workflow has completed, and a clean installation from the
366
+ new marketplace succeeds. Existing users must migrate explicitly; changing
367
+ a marketplace does not automatically move their installed plugin identity.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "9.1.0",
3
+ "version": "9.3.0",
4
4
  "description": "A native Codex dashboard for chats, usage, pull requests, and schedules.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Favo Yang",
@@ -0,0 +1,207 @@
1
+ import net from "node:net";
2
+ import { lstat } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+
7
+ // Private desktop IPC, observed in Codex 26.1007.30912. Never send decisions or
8
+ // thread mutations. Only initialize and subscribe/unsubscribe to local chats.
9
+ const STREAM_VERSION = 11;
10
+ const MAX_FRAME_BYTES = 32 * 1024 * 1024;
11
+ const text = value => typeof value === "string" ? value.slice(0, 4000) : null;
12
+ const approvalMethods = new Set(["item/commandExecution/requestApproval", "item/fileChange/requestApproval", "item/permissions/requestApproval"]);
13
+
14
+ function approval(request, task) {
15
+ const params = request?.params;
16
+ if (!params || params.threadId !== task.id || params.turnId !== task.turnId) return null;
17
+ const meta = params._meta ?? {};
18
+ const elicitation = request.method === "mcpServer/elicitation/request" && meta.codex_approval_kind === "mcp_tool_call";
19
+ if (!approvalMethods.has(request.method) && !elicitation) return null;
20
+ let origin = null;
21
+ try { const url = new URL(meta.origin); if (["http:", "https:"].includes(url.protocol)) origin = url.origin; } catch { /* Optional origin. */ }
22
+ return {
23
+ id: String(request.id), turnId: params.turnId,
24
+ title: text(meta.connector_name) ?? (request.method.includes("commandExecution") ? "Command execution" : request.method.includes("fileChange") ? "File changes" : "Permissions"),
25
+ message: text(params.message) ?? text(params.reason) ?? "Codex needs permission to continue.",
26
+ origin,
27
+ };
28
+ }
29
+
30
+ function validateState(state) {
31
+ const status = state.threadRuntimeStatus;
32
+ if (status != null && (typeof status !== "object" || typeof status.type !== "string"
33
+ || (status.type === "active" && (!Array.isArray(status.activeFlags) || !status.activeFlags.every(flag => typeof flag === "string"))))) throw new Error("Invalid Codex IPC runtime status");
34
+ if (!Array.isArray(state.requests) || !state.requests.every(request => request && typeof request === "object"
35
+ && typeof request.method === "string" && request.params && typeof request.params === "object")) throw new Error("Invalid Codex IPC approval requests");
36
+ return state;
37
+ }
38
+
39
+ // Apply only the two fields we consume. Conversation text, images, credentials,
40
+ // and unrelated patches are discarded and never enter TaskChef snapshots.
41
+ function patchState(state, patches) {
42
+ const result = structuredClone(state);
43
+ for (const patch of patches) {
44
+ if (!Array.isArray(patch.path) || !["threadRuntimeStatus", "requests"].includes(patch.path[0])) continue;
45
+ if (!["add", "replace", "remove"].includes(patch.op) || patch.path.some(key => ["__proto__", "prototype", "constructor"].includes(key))) throw new Error("Invalid live-state patch");
46
+ let parent = result;
47
+ for (const key of patch.path.slice(0, -1)) {
48
+ if (!parent || !Object.hasOwn(parent, key)) throw new Error("Missing live-state patch path");
49
+ parent = parent[key];
50
+ }
51
+ const key = patch.path.at(-1);
52
+ if (Array.isArray(parent)) {
53
+ if (!Number.isInteger(key) || key < 0 || key > parent.length || (patch.op !== "add" && key === parent.length)) throw new Error("Invalid live-state array patch");
54
+ if (patch.op === "remove") parent.splice(key, 1);
55
+ else if (patch.op === "add") parent.splice(key, 0, patch.value);
56
+ else parent[key] = patch.value;
57
+ } else {
58
+ if (!parent || typeof parent !== "object") throw new Error("Invalid live-state patch path");
59
+ if (patch.op === "remove") delete parent[key]; else parent[key] = patch.value;
60
+ }
61
+ }
62
+ return validateState(result);
63
+ }
64
+
65
+ export class CodexLiveState {
66
+ constructor({ codexHome = process.env.CODEX_HOME || join(homedir(), ".codex"), socketPath, now = Date.now, timeoutMs = 1000 } = {}) {
67
+ this.socketPath = socketPath ?? (process.platform === "win32" ? "\\\\.\\pipe\\codex-ipc" : join(codexHome, "ipc", "ipc.sock"));
68
+ this.now = now;
69
+ this.timeoutMs = timeoutMs;
70
+ this.entries = new Map();
71
+ this.retryAt = 0;
72
+ this.error = null;
73
+ this.closed = false;
74
+ }
75
+ async connect() {
76
+ if (this.clientId || this.closed || this.now() < this.retryAt) return;
77
+ if (this.connecting) return this.connecting;
78
+ this.connecting = (async () => {
79
+ try {
80
+ if (process.platform !== "win32") {
81
+ const [socket, directory] = await Promise.all([lstat(this.socketPath), lstat(dirname(this.socketPath))]);
82
+ if (!socket.isSocket() || socket.uid !== process.getuid() || !directory.isDirectory() || directory.uid !== process.getuid() || (directory.mode & 0o022)) throw new Error("Codex IPC socket ownership or permissions are invalid");
83
+ }
84
+ if (this.closed) return;
85
+ await new Promise(resolve => {
86
+ const socket = this.socket = net.createConnection(this.socketPath);
87
+ this.buffer = Buffer.alloc(0);
88
+ const timer = setTimeout(() => this.fail("Codex IPC initialization timed out"), this.timeoutMs);
89
+ this.finishConnect = () => { clearTimeout(timer); resolve(); };
90
+ socket.on("connect", () => this.send({ type: "request", requestId: randomUUID(), version: 0, method: "initialize", params: { clientType: "taskchef" } }));
91
+ socket.on("data", chunk => {
92
+ if (this.socket !== socket) return;
93
+ try {
94
+ this.buffer = Buffer.concat([this.buffer, chunk]);
95
+ while (this.buffer.length >= 4) {
96
+ const size = this.buffer.readUInt32LE();
97
+ if (!size || size > MAX_FRAME_BYTES) throw new Error("Codex IPC frame is invalid or too large");
98
+ if (this.buffer.length < size + 4) break;
99
+ const message = JSON.parse(this.buffer.subarray(4, size + 4));
100
+ this.buffer = this.buffer.subarray(size + 4);
101
+ this.receive(message);
102
+ }
103
+ } catch (error) { this.fail(error.message); }
104
+ });
105
+ socket.on("error", error => this.fail(`Codex IPC connection failed (${error.code ?? "error"})`));
106
+ socket.on("close", () => { if (this.socket === socket && !this.closed) this.fail("Codex IPC connection closed"); });
107
+ });
108
+ } catch (error) { this.fail(error.code ? `Codex IPC unavailable (${error.code})` : error.message); }
109
+ })().finally(() => { this.connecting = null; });
110
+ return this.connecting;
111
+ }
112
+ send(message) {
113
+ if (!this.socket || this.socket.destroyed) return;
114
+ const body = Buffer.from(JSON.stringify(message));
115
+ const frame = Buffer.alloc(4 + body.length);
116
+ frame.writeUInt32LE(body.length); body.copy(frame, 4);
117
+ this.socket.write(frame);
118
+ }
119
+ follow(id, following) {
120
+ this.send({ type: "broadcast", method: "thread-stream-following-changed", sourceClientId: this.clientId, version: 1, params: { conversationId: id, hostId: "local", following } });
121
+ }
122
+ receive(message) {
123
+ if (message.type === "response" && message.method === "initialize" && message.resultType === "success") {
124
+ if (typeof message.result?.clientId !== "string") throw new Error("Invalid Codex IPC initialization");
125
+ this.clientId = message.result.clientId;
126
+ this.finishConnect?.(); this.finishConnect = null;
127
+ return;
128
+ }
129
+ if (message.type === "client-discovery-request") {
130
+ this.send({ type: "client-discovery-response", requestId: message.requestId, response: { canHandle: false } });
131
+ return;
132
+ }
133
+ if (message.method === "client-status-changed" && message.params?.status === "disconnected") {
134
+ for (const entry of this.entries.values()) if (entry.owner === message.params.clientId) { entry.state = null; entry.revision = null; }
135
+ return;
136
+ }
137
+ if (message.type !== "broadcast" || message.method !== "thread-stream-state-changed" || message.params?.hostId !== "local") return;
138
+ const entry = this.entries.get(message.params.conversationId);
139
+ if (!entry) return;
140
+ if (message.version !== STREAM_VERSION) throw new Error("Codex IPC protocol changed; live approvals are unavailable");
141
+ const change = message.params.change;
142
+ if (!change || !Number.isSafeInteger(change.revision)) throw new Error("Invalid Codex IPC revision");
143
+ if (change.type === "snapshot") {
144
+ const state = change.conversationState;
145
+ if (state?.id !== message.params.conversationId || !Array.isArray(state.requests)) throw new Error("Invalid Codex IPC snapshot");
146
+ entry.state = validateState({ threadRuntimeStatus: state.threadRuntimeStatus, requests: state.requests });
147
+ entry.owner = message.sourceClientId;
148
+ } else if (change.type === "patches") {
149
+ if (!entry.state || entry.owner !== message.sourceClientId || entry.revision !== change.baseRevision) {
150
+ entry.state = null; entry.revision = null;
151
+ this.follow(message.params.conversationId, true);
152
+ return;
153
+ }
154
+ if (!Array.isArray(change.patches)) throw new Error("Invalid Codex IPC patches");
155
+ entry.state = patchState(entry.state, change.patches);
156
+ } else throw new Error("Unknown Codex IPC state message");
157
+ entry.revision = change.revision;
158
+ entry.ready?.(); entry.ready = null;
159
+ }
160
+ fail(message) {
161
+ this.error ??= message;
162
+ this.retryAt = this.now() + 15_000;
163
+ this.socket?.destroy(); this.socket = null; this.clientId = null; this.buffer = Buffer.alloc(0);
164
+ this.finishConnect?.(); this.finishConnect = null;
165
+ for (const entry of this.entries.values()) entry.ready?.();
166
+ this.entries.clear();
167
+ }
168
+ async refresh(tasks) {
169
+ if (this.closed) return;
170
+ const selected = tasks.filter(task => !task.observed?.archive && task.observed?.lastTurnEvent === "inProgress");
171
+ const ids = new Set(selected.map(task => task.id));
172
+ for (const [id, entry] of this.entries) if (!ids.has(id) || selected.find(task => task.id === id)?.turnId !== entry.turnId) {
173
+ this.follow(id, false); entry.ready?.(); this.entries.delete(id);
174
+ }
175
+ if (!selected.length) return;
176
+ await this.connect();
177
+ if (!this.clientId) return;
178
+ const waiting = [];
179
+ for (const task of selected) if (!this.entries.get(task.id)?.state) {
180
+ const entry = this.entries.get(task.id) ?? { turnId: task.turnId, state: null, revision: null };
181
+ entry.ready?.();
182
+ waiting.push(new Promise(resolve => { entry.ready = resolve; }));
183
+ this.entries.set(task.id, entry); this.follow(task.id, true);
184
+ }
185
+ if (waiting.length) {
186
+ let timer;
187
+ await Promise.race([Promise.all(waiting), new Promise(resolve => { timer = setTimeout(resolve, 250); })]);
188
+ clearTimeout(timer);
189
+ }
190
+ }
191
+ decorate(task) {
192
+ if (!task || task.observed?.archive || task.observed?.lastTurnEvent !== "inProgress") return task;
193
+ const entry = this.entries.get(task.id);
194
+ if (!entry?.state || entry.turnId !== task.turnId) return task;
195
+ const state = entry.state;
196
+ if (state.threadRuntimeStatus?.type !== "active" || !Array.isArray(state.threadRuntimeStatus.activeFlags) || !state.threadRuntimeStatus.activeFlags.includes("waitingOnApproval")) return task;
197
+ const pendingApprovals = state.requests.map(request => approval(request, task)).filter(Boolean);
198
+ // An old request must not decorate a newly selected database turn.
199
+ if (state.requests.length && !state.requests.some(request => request.params?.turnId === task.turnId)) return task;
200
+ return { ...task, needsApproval: true, pendingApprovals, status: "needs_input", statusLabel: "Needs approval", summary: "Codex is waiting for permission. Open the chat to review and approve." };
201
+ }
202
+ close() {
203
+ this.closed = true;
204
+ for (const id of this.entries.keys()) this.follow(id, false);
205
+ this.fail("Codex IPC closed");
206
+ }
207
+ }