taskchef 9.0.4 → 9.2.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/docs/mcp-app.md +39 -0
- package/package.json +1 -1
- package/src/codex-live-state.js +207 -0
- package/src/codex-projects.js +4 -2
- package/src/codex-session-scanner.js +4 -3
- package/src/mcp-app/dist/index.html +104 -94
- package/src/mcp-app/react/NextTaskDetail.test.tsx +13 -0
- package/src/mcp-app/react/NextTaskDetail.tsx +5 -2
- package/src/mcp-app/react/main.test.tsx +35 -1
- package/src/mcp-app/react/main.tsx +6 -3
- package/src/mcp-app/react/styles.css +6 -2
- package/src/mcp-app.js +15 -3
- package/src/next-notifications.js +29 -3
- package/src/ui/components/ApprovalNotice.tsx +24 -0
- package/src/ui/components/TaskBoard.test.tsx +22 -0
- package/src/ui/components/TaskBoard.tsx +4 -3
- package/src/ui/components/TaskCard.tsx +3 -1
- package/src/ui/styles.css +15 -0
- package/src/ui/types.ts +3 -1
package/docs/mcp-app.md
CHANGED
|
@@ -298,3 +298,42 @@ its cards, controls, borders, and elevated surfaces from those seeds. Links and
|
|
|
298
298
|
small muted text are adjusted when needed to meet a 4.5:1 contrast ratio on the
|
|
299
299
|
UI surfaces. Native font families use locally available fonts with system fallbacks. Font assets are not copied from the desktop application. Palette definitions ship with TaskChef; opening the board does not
|
|
300
300
|
read the installed desktop application or fetch theme files from the network.
|
|
301
|
+
|
|
302
|
+
The compact **Pinned** toggle button shows chats pinned in Codex and combines with project,
|
|
303
|
+
update window, and search filters. It starts off when the dashboard opens. Pin
|
|
304
|
+
changes appear on the next data refresh. The current desktop pin list takes
|
|
305
|
+
precedence over the database `is_pinned` field. TaskChef ignores old migration
|
|
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.
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/codex-projects.js
CHANGED
|
@@ -8,7 +8,7 @@ const contains = (root, cwd) => {
|
|
|
8
8
|
return child === "" || (!child.startsWith(`..${sep}`) && child !== ".." && !isAbsolute(child));
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
-
// Only project metadata and unread chat IDs leave this reader. Never return the desktop's other saved settings.
|
|
11
|
+
// Only project metadata, pin state, and unread chat IDs leave this reader. Never return the desktop's other saved settings.
|
|
12
12
|
export class CodexProjects {
|
|
13
13
|
constructor(codexHome) { this.codexHome = codexHome; this.saved = null; }
|
|
14
14
|
async desktopProjects() {
|
|
@@ -20,7 +20,9 @@ export class CodexProjects {
|
|
|
20
20
|
} catch (error) { if (error.code === "ENOENT") return {}; throw error; }
|
|
21
21
|
if (this.saved?.stamp === stamp) return this.saved.data;
|
|
22
22
|
const state = JSON.parse(await readFile(file, "utf8"));
|
|
23
|
+
const pinned = state["electron-persisted-atom-state"]?.["app-server-pinned-thread-order-v1"];
|
|
23
24
|
const data = {
|
|
25
|
+
pinned: Array.isArray(pinned) ? new Set(pinned.filter(id => typeof id === "string")) : null,
|
|
24
26
|
unread: localUnreadChats(state["electron-thread-read-state-v1"]),
|
|
25
27
|
assignments: state["thread-project-assignments"] ?? {},
|
|
26
28
|
hints: state["thread-workspace-root-hints"] ?? {}, projectless: new Set(state["projectless-thread-ids"] ?? []),
|
|
@@ -71,7 +73,7 @@ export class CodexProjects {
|
|
|
71
73
|
if (!explicit && !noProject && !hinted && !origins.has(cwd)) origins.set(cwd, this.worktreeRoot(cwd));
|
|
72
74
|
const origin = origins.has(cwd) ? await origins.get(cwd) : null;
|
|
73
75
|
const project = noProject ? null : explicit ?? hinted ?? matchRoot(origin || cwd);
|
|
74
|
-
return { ...task, ...(task.observed ? { observed: { ...task.observed, unread: saved.unread?.has(task.id) ?? false } } : {}), project: { ...task.project, id: project?.id ?? NO_PROJECT, name: project?.name ?? "No project" } };
|
|
76
|
+
return { ...task, ...(task.observed ? { observed: { ...task.observed, pinned: saved.pinned ? saved.pinned.has(task.id) : task.observed.pinned ?? false, unread: saved.unread?.has(task.id) ?? false } } : {}), project: { ...task.project, id: project?.id ?? NO_PROJECT, name: project?.name ?? "No project" } };
|
|
75
77
|
}));
|
|
76
78
|
return { tasks: grouped, projects: projects.map(({ id, name, roots }) => ({ id, name, path: roots[0] ?? "", githubRepos: [] })) };
|
|
77
79
|
}
|
|
@@ -233,12 +233,13 @@ function readDatabaseRecords(state, history) {
|
|
|
233
233
|
const eligible = `coalesce(thread_source, '') NOT IN ('subagent', 'guardian_review')
|
|
234
234
|
AND CASE WHEN json_valid(source) THEN json_type(source, '$.subagent') IS NULL ELSE 1 END
|
|
235
235
|
AND NOT EXISTS (SELECT 1 FROM thread_spawn_edges WHERE child_thread_id = threads.id)`;
|
|
236
|
+
const hasPinned = state.prepare("PRAGMA table_info(threads)").all().some(column => column.name === "is_pinned");
|
|
236
237
|
const hasTurnError = history.prepare("PRAGMA table_info(thread_turns)").all().some(column => column.name === "error_json");
|
|
237
238
|
const latestTurn = history.prepare(`SELECT turn_id, status, started_at, duration_ms, first_user_item_id, final_agent_item_id, ${hasTurnError ? "error_json" : "NULL AS error_json"}
|
|
238
239
|
FROM thread_turns WHERE thread_id = ? ORDER BY rollout_ordinal DESC LIMIT 1`);
|
|
239
240
|
const turns = new Map();
|
|
240
241
|
const needsAnswers = new Map();
|
|
241
|
-
const rows = state.prepare(`SELECT id, name, title, cwd, archived, source, created_at_ms, updated_at_ms, recency_at_ms, rollout_path, history_mode, project_id,
|
|
242
|
+
const rows = state.prepare(`SELECT id, name, title, cwd, archived, ${hasPinned ? "is_pinned" : "0 AS is_pinned"}, source, created_at_ms, updated_at_ms, recency_at_ms, rollout_path, history_mode, project_id,
|
|
242
243
|
(SELECT count(*) FROM thread_spawn_edges WHERE parent_thread_id = threads.id) AS child_count
|
|
243
244
|
FROM threads WHERE ${eligible} ORDER BY recency_at_ms DESC`).all().filter((row) => {
|
|
244
245
|
const key = historyId(row);
|
|
@@ -361,7 +362,7 @@ function buildDatabaseSnapshot(records, now, doneMarks, schedules, scheduleError
|
|
|
361
362
|
createdAt: iso(row.created_at_ms || updatedMs), updatedAt: iso(updatedMs),
|
|
362
363
|
updatedBy: "Local Codex database", project: { id: row.project_id, name: basename(cwd) || cwd || "Unknown project", path: cwd, githubRepos: [] },
|
|
363
364
|
threadId: row.id, turnRef: turn.turn_id, turnId: turn.turn_id, lastResult: null, latestTurn: null,
|
|
364
|
-
observed: { source: row.source, archive: Boolean(row.archived), lastTurnEvent: turn.status, lastTurnEventAt: turn.started_at ? iso(turn.started_at * 1000) : null, latestTurnDurationMs: turn.status !== "inProgress" && Number.isFinite(turn.duration_ms) && turn.duration_ms >= 0 ? turn.duration_ms : null, recentFileActivity: !row.archived && now - updatedMs < ACTIVE_WINDOW_MS, directChildCount: row.child_count },
|
|
365
|
+
observed: { pinned: Boolean(row.is_pinned), source: row.source, archive: Boolean(row.archived), lastTurnEvent: turn.status, lastTurnEventAt: turn.started_at ? iso(turn.started_at * 1000) : null, latestTurnDurationMs: turn.status !== "inProgress" && Number.isFinite(turn.duration_ms) && turn.duration_ms >= 0 ? turn.duration_ms : null, recentFileActivity: !row.archived && now - updatedMs < ACTIVE_WINDOW_MS, directChildCount: row.child_count },
|
|
365
366
|
};
|
|
366
367
|
});
|
|
367
368
|
return { tasks, rolloutPaths: new Map(rows.map((row) => [row.id, row.rollout_path])), scan: {
|
|
@@ -571,7 +572,7 @@ export class CodexSessionScanner {
|
|
|
571
572
|
const nextTasks = new Map(grouped.tasks.map((task) => [task.id, task]));
|
|
572
573
|
const signature = (tasks) => JSON.stringify([...tasks.values()].map((task) => [
|
|
573
574
|
task.id, task.title, task.project.path, task.project.id, task.project.name, task.updatedAt, task.status, task.summary, task.turnError,
|
|
574
|
-
task.observed.archive, task.observed.unread, task.observed.lastTurnEvent, task.observed.directChildCount, task.observed.latestTurnDurationMs,
|
|
575
|
+
task.observed.archive, task.observed.pinned, task.observed.unread, task.observed.lastTurnEvent, task.observed.directChildCount, task.observed.latestTurnDurationMs,
|
|
575
576
|
task.scheduled, task.nextRunAt, task.inputSource, task.turnId, task.promptExcerpt, task.replyExcerpt, task.annotations, task.promptImages, task.promptImage, task.replyImage, task.queuedPrompts, task.needsAnswer, task.pendingQuestions, task.pullRequests,
|
|
576
577
|
]));
|
|
577
578
|
if (this.stats?.mode !== "database" || signature(this.tasks) !== signature(nextTasks) || JSON.stringify(this.projects) !== JSON.stringify(grouped.projects)) this.revision += 1;
|