daily-focus 1.1.1-dev.3 → 1.2.0-dev.10

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.
@@ -0,0 +1,1003 @@
1
+ import {
2
+ canonicalId,
3
+ foldActionLog
4
+ } from "./chunk-RZNQJ6UG.js";
5
+ import {
6
+ laterISO,
7
+ localDateKey,
8
+ parseISO
9
+ } from "./chunk-J7ZYVUG6.js";
10
+
11
+ // src/codex.ts
12
+ import { execFile } from "node:child_process";
13
+ import { promisify } from "node:util";
14
+ var GMAIL_CONNECTOR = "connector_2128aebfecb84f64a069897515042a44";
15
+ var CONNECTOR_POLICY = [
16
+ 'apps._default.default_tools_approval_mode="writes"',
17
+ 'apps._default.approvals_reviewer="user"'
18
+ ];
19
+ var GMAIL_DRAFTS = ["create_draft", "update_draft"].map(
20
+ (tool) => `apps.${GMAIL_CONNECTOR}.tools.${tool}.approval_mode="approve"`
21
+ );
22
+ var COMPUTER_USE_PLUGINS = [
23
+ "unified-computer-use@openai-bundled",
24
+ "computer-use@openai-bundled",
25
+ "browser@openai-bundled",
26
+ "chrome@openai-bundled"
27
+ ];
28
+ var COMPUTER_USE_SERVERS = ["node_repl", "cua_repl", "computer-use"];
29
+ var LIST_TIMEOUT_MS = 3e4;
30
+ var exec = promisify(execFile);
31
+ var defaultExec = async (file, args, { timeout }) => {
32
+ try {
33
+ const { stdout } = await exec(file, args, { timeout, maxBuffer: 16 * 1024 * 1024 });
34
+ return stdout;
35
+ } catch (err) {
36
+ const stderr = String(err.stderr ?? "").trim();
37
+ if (!stderr) throw err;
38
+ throw new Error(stderr.split("\n").slice(0, 2).join(" "));
39
+ }
40
+ };
41
+ function configArgs(overrides) {
42
+ return overrides.flatMap((override) => ["-c", override]);
43
+ }
44
+ async function enabledServers(bin, overrides, run) {
45
+ const output = await run(bin, ["mcp", "list", "--json", ...configArgs(overrides)], { timeout: LIST_TIMEOUT_MS });
46
+ let servers;
47
+ try {
48
+ servers = JSON.parse(output);
49
+ } catch {
50
+ throw new Error("codex mcp list --json printed something other than JSON");
51
+ }
52
+ if (!Array.isArray(servers)) throw new Error("codex mcp list --json printed something other than a list");
53
+ return servers.filter((server) => typeof server?.name === "string").filter((server) => server.enabled !== false).map((server) => server.name);
54
+ }
55
+ async function computerUseOff(bin, run = defaultExec) {
56
+ const overrides = COMPUTER_USE_PLUGINS.map((plugin) => `plugins.${plugin}.enabled=false`);
57
+ const left = (await enabledServers(bin, overrides, run)).filter((name) => COMPUTER_USE_SERVERS.includes(name));
58
+ if (left.length === 0) return overrides;
59
+ overrides.push(...left.map((name) => `mcp_servers.${name}.enabled=false`));
60
+ const still = (await enabledServers(bin, overrides, run)).filter((name) => COMPUTER_USE_SERVERS.includes(name));
61
+ if (still.length > 0) throw new Error(`Codex still starts ${still.join(", ")}`);
62
+ return overrides;
63
+ }
64
+ var CODEX_CONNECTOR_POLICY = "0.144.0";
65
+ var CODEX_MINIMUM = "0.149.0";
66
+ function versionParts(text) {
67
+ const match = /(\d+)\.(\d+)\.(\d+)/.exec(text);
68
+ return match ? match.slice(1, 4).map(Number) : null;
69
+ }
70
+ function olderThan(version, than) {
71
+ const a = versionParts(version);
72
+ const b = versionParts(than);
73
+ if (!a || !b) return false;
74
+ for (let i = 0; i < 3; i++) {
75
+ if (a[i] !== b[i]) return a[i] < b[i];
76
+ }
77
+ return false;
78
+ }
79
+ async function codexVersion(bin, run = defaultExec) {
80
+ try {
81
+ return versionParts(await run(bin, ["--version"], { timeout: LIST_TIMEOUT_MS }))?.join(".") ?? null;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+ function refusalFor(version) {
87
+ if (!version || !olderThan(version, CODEX_CONNECTOR_POLICY)) return null;
88
+ return `Codex ${version} is older than ${CODEX_CONNECTOR_POLICY}, which ignores the dashboard's refusal of connector writes without saying so, so the dashboard won't run it. Update Codex`;
89
+ }
90
+ var CodexGate = class {
91
+ #bin;
92
+ #run;
93
+ #cleared = false;
94
+ constructor(bin, run = defaultExec) {
95
+ this.#bin = bin;
96
+ this.#run = run;
97
+ }
98
+ async refusal() {
99
+ if (this.#cleared) return null;
100
+ const version = await codexVersion(this.#bin, this.#run);
101
+ const refusal = refusalFor(version);
102
+ if (version && !refusal) this.#cleared = true;
103
+ return refusal;
104
+ }
105
+ };
106
+ async function codexVersionWarning(bin, run = defaultExec) {
107
+ const version = await codexVersion(bin, run);
108
+ if (!version || !olderThan(version, CODEX_MINIMUM)) return null;
109
+ const consequence = olderThan(version, CODEX_CONNECTOR_POLICY) ? `Before ${CODEX_CONNECTOR_POLICY} Codex ignores the dashboard's refusal of connector writes without saying so, so the dashboard won't start the agent or the assistant on it.` : `Before ${CODEX_MINIMUM} Codex can't switch computer use off, so the assistant won't answer while computer use is off in its settings.`;
110
+ return `Codex ${version} is older than ${CODEX_MINIMUM}, the oldest the dashboard's limits on Codex work with. ${consequence} Update Codex.`;
111
+ }
112
+
113
+ // src/assistant.ts
114
+ import { spawn } from "node:child_process";
115
+ import { randomUUID } from "node:crypto";
116
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
117
+ import { createInterface } from "node:readline";
118
+ var TURN_TIMEOUT_MS = 10 * 6e4;
119
+ var RESTARTED = "the dashboard restarted while this was running";
120
+ var SHUT_DOWN = "the dashboard shut down";
121
+ var TURNS_SHOWN = 20;
122
+ var STEPS_KEPT = 100;
123
+ var DETAIL_LENGTH = 200;
124
+ var QUICK_ACTIONS = [
125
+ {
126
+ id: "assess-review",
127
+ label: "Assess the review",
128
+ sources: ["github"],
129
+ request: "Read every review comment and requested change on this pull request against the diff. For each one say whether it is right, partly right or mistaken, with the evidence, and what I should do about it."
130
+ },
131
+ {
132
+ id: "explain-ci",
133
+ label: "Explain the CI failure",
134
+ sources: ["github"],
135
+ request: "Find the failing checks on this pull request and read their logs. Explain what is failing, why, and the smallest change that would fix it. Do not attempt the fix."
136
+ },
137
+ {
138
+ id: "summarise-pr",
139
+ label: "Summarise the change",
140
+ sources: ["github"],
141
+ request: "Summarise what this pull request changes and why, in a form I could paste as its description, and flag anything a reviewer is likely to push back on."
142
+ },
143
+ {
144
+ id: "reply-reviewer",
145
+ label: "Draft replies to reviewers",
146
+ sources: ["github"],
147
+ request: "Draft a reply to each open review comment on this pull request, in my voice, agreeing where the reviewer is right and explaining where they are not. Give me the text to post. Do not post it."
148
+ },
149
+ {
150
+ id: "draft-reply",
151
+ label: "Draft a reply",
152
+ sources: ["email"],
153
+ request: "Read the whole thread and draft a reply from me, in my voice, that answers what was asked. Create it as a Gmail draft on this thread so I can open it there. Never send it. If you cannot create drafts, give me the text instead and say so."
154
+ },
155
+ {
156
+ id: "summarise-thread",
157
+ label: "Summarise the thread",
158
+ sources: ["email"],
159
+ request: "Read the whole thread and summarise it: who wants what, what has been decided, and what is still open."
160
+ },
161
+ {
162
+ id: "what-they-need",
163
+ label: "What do they need from me?",
164
+ sources: ["email", "slack", "messages"],
165
+ request: "Read this and tell me exactly what is being asked of me, by whom, by when, and what the smallest adequate response would be."
166
+ },
167
+ {
168
+ id: "summarise-ticket",
169
+ label: "Summarise the ticket",
170
+ sources: ["jira"],
171
+ request: "Read this ticket, its comments and anything it links to, and summarise where it stands: what is done, what is left, and what is blocking it."
172
+ },
173
+ {
174
+ id: "status-comment",
175
+ label: "Draft a status comment",
176
+ sources: ["jira"],
177
+ request: "Read this ticket and its linked pull requests, and draft a short status comment from me for the ticket. Give me the text to post. Do not post it."
178
+ },
179
+ {
180
+ id: "next-steps",
181
+ label: "Break it into next steps",
182
+ sources: null,
183
+ request: "Read whatever this item points at and turn it into three to five concrete next steps I could start on today, smallest first."
184
+ }
185
+ ];
186
+ function foldAssistantLog(text) {
187
+ const turns = /* @__PURE__ */ new Map();
188
+ for (const line of text.split("\n")) {
189
+ if (line.trim() === "") continue;
190
+ let record;
191
+ try {
192
+ record = JSON.parse(line);
193
+ } catch {
194
+ continue;
195
+ }
196
+ if (typeof record !== "object" || record === null || typeof record.turn !== "string") continue;
197
+ if (record.event === "started") {
198
+ if (typeof record.item !== "string" || typeof record.request !== "string") continue;
199
+ turns.set(record.turn, {
200
+ id: record.turn,
201
+ itemId: record.item,
202
+ agent: record.agent,
203
+ sessionId: null,
204
+ request: record.request,
205
+ action: typeof record.action === "string" ? record.action : null,
206
+ auto: typeof record.auto === "string" ? record.auto : null,
207
+ startedAt: record.at,
208
+ endedAt: null,
209
+ status: "running",
210
+ reply: "",
211
+ steps: [],
212
+ error: null
213
+ });
214
+ continue;
215
+ }
216
+ const turn = turns.get(record.turn);
217
+ if (!turn) continue;
218
+ turn.sessionId = typeof record.sessionId === "string" ? record.sessionId : turn.sessionId;
219
+ turn.reply = typeof record.reply === "string" ? record.reply : turn.reply;
220
+ turn.steps = readSteps(record.steps);
221
+ turn.endedAt = record.at;
222
+ if (record.event === "finished") {
223
+ turn.status = "done";
224
+ } else if (record.event === "failed") {
225
+ turn.status = "failed";
226
+ turn.error = record.error;
227
+ } else if (record.event === "aborted") {
228
+ turn.status = "aborted";
229
+ turn.error = record.reason;
230
+ }
231
+ }
232
+ return turns;
233
+ }
234
+ function addStep(turn, step) {
235
+ turn.steps.push(step);
236
+ if (turn.steps.length > STEPS_KEPT) turn.steps.shift();
237
+ }
238
+ function sameWords(a, b) {
239
+ return a.replace(/\s+/g, " ").trim() === b.replace(/\s+/g, " ").trim();
240
+ }
241
+ function readSteps(value) {
242
+ if (!Array.isArray(value)) return [];
243
+ const steps = [];
244
+ for (const candidate of value) {
245
+ if (typeof candidate !== "object" || candidate === null || typeof candidate.text !== "string") continue;
246
+ steps.push({
247
+ kind: candidate.kind === "message" ? "message" : "tool",
248
+ text: candidate.text,
249
+ detail: typeof candidate.detail === "string" ? candidate.detail : null,
250
+ state: candidate.state === "failed" ? "failed" : "done"
251
+ });
252
+ }
253
+ return steps;
254
+ }
255
+ function parseLine(line) {
256
+ const trimmed = line.trim();
257
+ if (!trimmed.startsWith("{")) return null;
258
+ try {
259
+ const parsed = JSON.parse(trimmed);
260
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+ var claudeAdapter = {
266
+ args({ prompt, sessionId, model, effort, tools, denyEdits }) {
267
+ const args = [
268
+ "-p",
269
+ "--output-format",
270
+ "stream-json",
271
+ "--verbose",
272
+ "--permission-mode",
273
+ "dontAsk",
274
+ "--permission-prompts",
275
+ "none"
276
+ ];
277
+ if (denyEdits) args.push("--disallowedTools", "Edit", "Write", "NotebookEdit");
278
+ if (tools.length > 0) args.push("--allowedTools", ...tools);
279
+ if (model) args.push("--model", model);
280
+ if (effort) args.push("--effort", effort);
281
+ if (sessionId) args.push("--resume", sessionId);
282
+ args.push("--", prompt);
283
+ return args;
284
+ },
285
+ parse(line) {
286
+ const event = parseLine(line);
287
+ if (!event) return null;
288
+ const type = event.type;
289
+ if (type === "system" && typeof event.session_id === "string") {
290
+ return { sessionId: event.session_id };
291
+ }
292
+ if (type === "assistant" || type === "user") {
293
+ const message = event.message;
294
+ const content = Array.isArray(message?.content) ? message.content : [];
295
+ const parsed = {};
296
+ const text = type === "assistant" ? content.filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n\n") : "";
297
+ if (text) parsed.text = text;
298
+ const steps = content.flatMap((block) => claudeStep(block) ?? []);
299
+ if (steps.length) parsed.steps = steps;
300
+ return text || steps.length ? parsed : null;
301
+ }
302
+ if (type === "result") {
303
+ const ok = event.is_error !== true && event.subtype === "success";
304
+ const reply = typeof event.result === "string" ? event.result : void 0;
305
+ const parsed = { done: ok ? { ok, reply } : { ok, error: reply ?? String(event.subtype ?? "failed") } };
306
+ if (typeof event.session_id === "string") parsed.sessionId = event.session_id;
307
+ return parsed;
308
+ }
309
+ return null;
310
+ }
311
+ };
312
+ var codexAdapter = {
313
+ args({ prompt, sessionId, model, effort, drafts, codexConfig }) {
314
+ const args = ["exec"];
315
+ if (sessionId) args.push("resume");
316
+ args.push("--json", "--skip-git-repo-check");
317
+ const config = [
318
+ 'sandbox_mode="workspace-write"',
319
+ "sandbox_workspace_write.network_access=true",
320
+ ...CONNECTOR_POLICY,
321
+ ...drafts ? GMAIL_DRAFTS : [],
322
+ ...codexConfig
323
+ ];
324
+ if (model) config.push(`model=${JSON.stringify(model)}`);
325
+ if (effort) config.push(`model_reasoning_effort=${JSON.stringify(effort)}`);
326
+ args.push(...configArgs(config));
327
+ if (sessionId) args.push(sessionId);
328
+ args.push(prompt);
329
+ return args;
330
+ },
331
+ parse(line) {
332
+ const event = parseLine(line);
333
+ if (!event) return null;
334
+ const type = event.type;
335
+ if (type === "thread.started" && typeof event.thread_id === "string") {
336
+ return { sessionId: event.thread_id };
337
+ }
338
+ if (type === "item.started" || type === "item.completed") {
339
+ const item = event.item;
340
+ if (type === "item.completed" && item?.type === "agent_message" && typeof item.text === "string") return { text: item.text };
341
+ const step = item ? codexStep(item, type === "item.completed") : null;
342
+ return step ? { steps: [step] } : null;
343
+ }
344
+ if (type === "turn.completed") return { done: { ok: true } };
345
+ if (type === "turn.failed" || type === "error") {
346
+ const error = event.error;
347
+ const message = typeof event.message === "string" ? event.message : error?.message;
348
+ return { done: { ok: false, error: message ?? String(type) } };
349
+ }
350
+ return null;
351
+ }
352
+ };
353
+ var ADAPTERS = {
354
+ claude: claudeAdapter,
355
+ codex: codexAdapter
356
+ };
357
+ var CLAUDE_QUIET_TOOLS = /* @__PURE__ */ new Set(["ToolSearch", "TodoWrite"]);
358
+ function claudeStep(block) {
359
+ if (block.type === "tool_result" && typeof block.tool_use_id === "string") {
360
+ return { id: block.tool_use_id, state: block.is_error === true ? "failed" : "done" };
361
+ }
362
+ if (block.type !== "tool_use" || typeof block.id !== "string" || typeof block.name !== "string") return null;
363
+ if (CLAUDE_QUIET_TOOLS.has(block.name)) return null;
364
+ const input = block.input ?? {};
365
+ const said = (key) => typeof input[key] === "string" && input[key] !== "" ? input[key] : null;
366
+ const step = (text, detail) => ({ id: block.id, text, detail: shorten(detail), state: "running" });
367
+ switch (block.name) {
368
+ case "Bash":
369
+ return step(said("description") ?? "Run a command", said("command"));
370
+ case "WebFetch":
371
+ return step("Fetch a page", said("url"));
372
+ case "WebSearch":
373
+ return step("Search the web", said("query"));
374
+ case "Read":
375
+ return step("Read a file", said("file_path"));
376
+ case "Grep":
377
+ return step("Search files", said("pattern"));
378
+ case "Glob":
379
+ return step("List files", said("pattern"));
380
+ case "Task":
381
+ case "Agent":
382
+ return step("Hand part of it to a subagent", said("description"));
383
+ }
384
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(block.name);
385
+ if (mcp) return step(connectorLabel(mcp[1], mcp[2]), compactArgs(input));
386
+ return step(block.name, compactArgs(input));
387
+ }
388
+ function codexStep(item, completed) {
389
+ if (typeof item.id !== "string") return null;
390
+ const failed = item.status === "failed" || item.error !== void 0 && item.error !== null;
391
+ const state = completed ? failed ? "failed" : "done" : "running";
392
+ if (item.type === "command_execution") {
393
+ const exited = typeof item.exit_code === "number" && item.exit_code !== 0;
394
+ return {
395
+ id: item.id,
396
+ text: "Run a command",
397
+ detail: shorten(unwrapShell(item.command ?? "")),
398
+ state: completed && exited ? "failed" : state
399
+ };
400
+ }
401
+ if (item.type === "mcp_tool_call" && typeof item.server === "string" && typeof item.tool === "string") {
402
+ return { id: item.id, text: connectorLabel(item.server, item.tool), detail: compactArgs(item.arguments), state };
403
+ }
404
+ if (item.type === "web_search") {
405
+ const action = item.action ?? {};
406
+ if (action.type === "open_page") return { id: item.id, text: "Open a page", detail: shorten(action.url ?? null), state };
407
+ if (action.type === "find_in_page") return { id: item.id, text: "Find in a page", detail: shorten(action.pattern ?? action.url ?? null), state };
408
+ return { id: item.id, text: "Search the web", detail: shorten(action.query || item.query || null), state };
409
+ }
410
+ return null;
411
+ }
412
+ function unwrapShell(command) {
413
+ const wrapped = /^(?:\S*\/)?(?:ba|z)?sh\s+-l?c\s+(['"])([\s\S]*)\1$/.exec(command.trim());
414
+ if (!wrapped) return command.trim();
415
+ return wrapped[1] === "'" ? wrapped[2].replaceAll(`'\\''`, `'`) : wrapped[2];
416
+ }
417
+ function connectorLabel(server, tool) {
418
+ const words = (name) => name.replace(/[_-]+/g, " ").trim();
419
+ if (server === "codex_apps") {
420
+ const said = words(tool);
421
+ return said.charAt(0).toUpperCase() + said.slice(1);
422
+ }
423
+ return `${words(server.replace(/^claude_ai_/, ""))}: ${words(tool)}`;
424
+ }
425
+ function compactArgs(input) {
426
+ if (input === null || input === void 0) return null;
427
+ if (typeof input === "object" && Object.keys(input).length === 0) return null;
428
+ return shorten(typeof input === "string" ? input : JSON.stringify(input));
429
+ }
430
+ function shorten(value) {
431
+ if (!value) return null;
432
+ const lines = value.trim().split("\n");
433
+ const first = lines[0].trim();
434
+ const more = lines.length > 1;
435
+ if (first.length > DETAIL_LENGTH) return `${first.slice(0, DETAIL_LENGTH - 1)}\u2026`;
436
+ return more ? `${first} \u2026` : first;
437
+ }
438
+ function composePrompt(instructions, context, request, profile) {
439
+ const now = /* @__PURE__ */ new Date();
440
+ const facts = [`- Profile: ${profile}`, `- Today: ${now.toISOString().slice(0, 10)}`];
441
+ const parts = [instructions.trim(), "", "## Context", "", ...facts, ""];
442
+ if (context.item) parts.push("### The item", "", "```json", JSON.stringify(context.item, null, 2), "```", "");
443
+ if (context.pull) parts.push("### The pull request, as the board last saw it", "", "```json", JSON.stringify(context.pull, null, 2), "```", "");
444
+ if (context.ticket) parts.push("### The ticket, as the board last saw it", "", "```json", JSON.stringify(context.ticket, null, 2), "```", "");
445
+ parts.push("## Request", "", request.trim());
446
+ return parts.join("\n");
447
+ }
448
+ var Assistant = class _Assistant {
449
+ turns = /* @__PURE__ */ new Map();
450
+ running = /* @__PURE__ */ new Map();
451
+ loaded = false;
452
+ config;
453
+ hooks;
454
+ spawnFn;
455
+ execFn;
456
+ /** Refuses a Codex too old to hold to the connector rules; see `codex.ts`. */
457
+ codexGate;
458
+ /** What switches computer use off, once Codex has said; see `computerUseConfig`. */
459
+ codexOff = null;
460
+ constructor(config, hooks, spawnFn = spawn, execFn) {
461
+ this.config = config;
462
+ this.hooks = hooks;
463
+ this.spawnFn = spawnFn;
464
+ this.execFn = execFn;
465
+ this.codexGate = new CodexGate(config.assistant.binPath, execFn);
466
+ }
467
+ get enabled() {
468
+ return this.config.assistant.agent !== null;
469
+ }
470
+ /** Whether any turn has a process going right now. */
471
+ get busy() {
472
+ return this.running.size > 0;
473
+ }
474
+ /**
475
+ * Read the log, and close whatever was left running when the last process
476
+ * died. Cheap when the assistant is off: the log is read only if it exists.
477
+ */
478
+ async start() {
479
+ if (this.loaded) return;
480
+ this.loaded = true;
481
+ let text = "";
482
+ try {
483
+ text = await readFile(this.config.assistantLogFile, "utf8");
484
+ } catch {
485
+ return;
486
+ }
487
+ for (const [id, turn] of foldAssistantLog(text)) this.turns.set(id, turn);
488
+ for (const turn of this.turns.values()) {
489
+ if (turn.status !== "running") continue;
490
+ turn.status = "aborted";
491
+ turn.error = RESTARTED;
492
+ turn.endedAt = (/* @__PURE__ */ new Date()).toISOString();
493
+ await this.append({
494
+ event: "aborted",
495
+ turn: turn.id,
496
+ sessionId: turn.sessionId,
497
+ reason: turn.error,
498
+ reply: turn.reply,
499
+ steps: turn.steps,
500
+ at: turn.endedAt
501
+ });
502
+ }
503
+ }
504
+ /**
505
+ * Every turn the dashboard started on its own, however old: what has been
506
+ * asked ahead is never asked again, and the panel's window of recent turns
507
+ * is no record of that.
508
+ */
509
+ autoTurns() {
510
+ return [...this.turns.values()].filter((turn) => turn.auto !== null);
511
+ }
512
+ /** The assistant's half of the state. Asking ahead is `autoask.ts`'s to add, since it needs the board. */
513
+ view() {
514
+ const items = {};
515
+ for (const turn of this.turns.values()) {
516
+ const entry = items[turn.itemId] ??= { running: false, sessionId: null, turns: [] };
517
+ entry.turns.push(turn);
518
+ if (turn.status === "running") entry.running = true;
519
+ if (turn.sessionId && turn.agent === this.config.assistant.agent) entry.sessionId = turn.sessionId;
520
+ }
521
+ for (const entry of Object.values(items)) {
522
+ entry.turns.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
523
+ entry.turns = entry.turns.slice(-TURNS_SHOWN);
524
+ }
525
+ return {
526
+ enabled: this.enabled,
527
+ agent: this.config.assistant.agent,
528
+ quickActions: [...QUICK_ACTIONS],
529
+ items
530
+ };
531
+ }
532
+ /** The request text an ask resolves to, or null when there is nothing to send. */
533
+ static requestFor(ask) {
534
+ const action = ask.action ? QUICK_ACTIONS.find((candidate) => candidate.id === ask.action) : void 0;
535
+ if (ask.action && !action) return null;
536
+ const typed = (ask.text ?? "").trim();
537
+ if (!action && typed === "") return null;
538
+ const request = action ? typed ? `${action.request}
539
+
540
+ ${typed}` : action.request : typed;
541
+ return { request, action: action?.id ?? null };
542
+ }
543
+ /**
544
+ * Start a turn. Returns once the process is running, not once it has answered;
545
+ * the answer arrives through `onChange` and the next state. `auto` marks a
546
+ * turn the dashboard started on its own, with the key of what it answers.
547
+ */
548
+ async ask(ask, context, options = {}) {
549
+ const agent = this.config.assistant.agent;
550
+ if (!agent) throw new Error("the assistant is off; set DAILY_FOCUS_ASSISTANT to claude or codex");
551
+ if (this.running.has(ask.itemId)) throw new Error("the assistant is already working on this item");
552
+ const resolved = _Assistant.requestFor(ask);
553
+ if (!resolved) throw new Error("nothing to ask: give a quick action or some text");
554
+ await this.start();
555
+ const previous = this.view().items[ask.itemId]?.sessionId ?? null;
556
+ const turn = {
557
+ id: randomUUID(),
558
+ itemId: ask.itemId,
559
+ agent,
560
+ // Null until the CLI says: a resumed session that fails must not be
561
+ // recorded as if this CLI had owned it.
562
+ sessionId: null,
563
+ request: resolved.request,
564
+ action: resolved.action,
565
+ auto: options.auto ?? null,
566
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
567
+ endedAt: null,
568
+ status: "running",
569
+ reply: "",
570
+ steps: [],
571
+ error: null
572
+ };
573
+ this.turns.set(turn.id, turn);
574
+ await this.append({
575
+ event: "started",
576
+ turn: turn.id,
577
+ item: turn.itemId,
578
+ agent,
579
+ request: turn.request,
580
+ action: turn.action,
581
+ ...turn.auto ? { auto: turn.auto } : {},
582
+ at: turn.startedAt
583
+ });
584
+ const prompt = previous ? resolved.request : composePrompt(await this.instructions(), context, resolved.request, this.config.profile);
585
+ await mkdir(this.config.assistantDir, { recursive: true });
586
+ const refusal = agent === "codex" ? await this.codexGate.refusal() : null;
587
+ if (refusal) {
588
+ await this.finish(turn, { ok: false, error: refusal });
589
+ return turn;
590
+ }
591
+ let codexConfig;
592
+ try {
593
+ codexConfig = await this.computerUseConfig();
594
+ } catch (err) {
595
+ const error = err.code === "ENOENT" ? this.spawnError(err) : `computer use is off in the settings, and Codex could not confirm it: ${err.message}. Update Codex, or turn computer use on for the assistant`;
596
+ await this.finish(turn, { ok: false, error });
597
+ return turn;
598
+ }
599
+ const adapter = ADAPTERS[agent];
600
+ const args = adapter.args({
601
+ prompt,
602
+ sessionId: previous,
603
+ model: this.config.assistant.model,
604
+ effort: this.config.assistant.effort,
605
+ tools: this.config.assistant.tools,
606
+ denyEdits: true,
607
+ drafts: true,
608
+ codexConfig
609
+ });
610
+ let child;
611
+ try {
612
+ child = this.spawnFn(this.config.assistant.binPath, args, {
613
+ cwd: this.config.assistantDir,
614
+ // Stdin closed: both CLIs wait on it otherwise, one of them for three seconds.
615
+ stdio: ["ignore", "pipe", "pipe"],
616
+ env: process.env
617
+ });
618
+ } catch (err) {
619
+ await this.finish(turn, { ok: false, error: this.spawnError(err) });
620
+ return turn;
621
+ }
622
+ const timer = setTimeout(() => this.stop(turn.itemId, "gave up after ten minutes"), TURN_TIMEOUT_MS);
623
+ timer.unref();
624
+ const run = { turn, child, timer, abortReason: null };
625
+ this.running.set(turn.itemId, run);
626
+ this.hooks.onChange();
627
+ let done;
628
+ const stderr = [];
629
+ child.stderr?.on("data", (chunk) => {
630
+ stderr.push(chunk.toString("utf8"));
631
+ if (stderr.length > 50) stderr.shift();
632
+ });
633
+ const calls = /* @__PURE__ */ new Map();
634
+ if (child.stdout) {
635
+ const lines = createInterface({ input: child.stdout });
636
+ lines.on("line", (line) => {
637
+ const event = adapter.parse(line);
638
+ if (!event) return;
639
+ if (event.sessionId) turn.sessionId = event.sessionId;
640
+ if (event.text) addStep(turn, { kind: "message", text: event.text, detail: null, state: "done" });
641
+ for (const parsed of event.steps ?? []) {
642
+ const known = calls.get(parsed.id);
643
+ if (known) {
644
+ if (parsed.text) known.text = parsed.text;
645
+ if (parsed.detail) known.detail = parsed.detail;
646
+ known.state = parsed.state;
647
+ } else if (parsed.text) {
648
+ const step = { kind: "tool", text: parsed.text, detail: parsed.detail ?? null, state: parsed.state };
649
+ calls.set(parsed.id, step);
650
+ addStep(turn, step);
651
+ }
652
+ }
653
+ if (event.text || event.steps) this.hooks.onChange();
654
+ if (event.done) done = event.done;
655
+ });
656
+ }
657
+ child.on("error", (err) => {
658
+ done = { ok: false, error: this.spawnError(err) };
659
+ });
660
+ child.on("close", (code) => {
661
+ clearTimeout(timer);
662
+ this.running.delete(turn.itemId);
663
+ const outcome = run.abortReason ? { ok: false, error: run.abortReason } : done ?? {
664
+ ok: false,
665
+ error: `${agent} exited with code ${code}${stderr.length ? `: ${stderr.join("").trim().split("\n").slice(-3).join(" ")}` : ""}`
666
+ };
667
+ void this.finish(turn, outcome, run.abortReason !== null);
668
+ });
669
+ return turn;
670
+ }
671
+ /** Kill the turn running on an item, if any. */
672
+ async stop(itemId, reason = "stopped") {
673
+ const run = this.running.get(itemId);
674
+ if (!run) return false;
675
+ run.abortReason = reason;
676
+ run.child.kill("SIGTERM");
677
+ return true;
678
+ }
679
+ /** Kill everything, for shutdown. The exit handlers write the aborted lines. */
680
+ async close() {
681
+ for (const itemId of [...this.running.keys()]) await this.stop(itemId, SHUT_DOWN);
682
+ }
683
+ async finish(turn, outcome, aborted = false) {
684
+ const at = (/* @__PURE__ */ new Date()).toISOString();
685
+ const last = turn.steps.findLast((step) => step.kind === "message");
686
+ const reply = outcome.ok ? outcome.reply ?? last?.text ?? "" : "";
687
+ if (outcome.ok && last && sameWords(last.text, reply)) turn.steps.splice(turn.steps.lastIndexOf(last), 1);
688
+ for (const step of turn.steps) if (step.state === "running") step.state = outcome.ok ? "done" : "failed";
689
+ turn.reply = reply;
690
+ turn.endedAt = at;
691
+ const steps = turn.steps;
692
+ if (outcome.ok) {
693
+ turn.status = "done";
694
+ await this.append({ event: "finished", turn: turn.id, sessionId: turn.sessionId, reply, steps, at });
695
+ } else if (aborted) {
696
+ turn.status = "aborted";
697
+ turn.error = outcome.error ?? "stopped";
698
+ await this.append({ event: "aborted", turn: turn.id, sessionId: turn.sessionId, reason: turn.error, reply, steps, at });
699
+ } else {
700
+ turn.status = "failed";
701
+ turn.error = outcome.error ?? "failed";
702
+ await this.append({ event: "failed", turn: turn.id, sessionId: turn.sessionId, error: turn.error, reply, steps, at });
703
+ }
704
+ this.hooks.onChange();
705
+ }
706
+ /**
707
+ * What switches computer use off, when the assistant runs Codex and the user
708
+ * hasn't turned it on. Asked of Codex once and kept for the life of the
709
+ * process; settings that change it restart the dashboard anyway. A failure
710
+ * isn't kept, so the next turn asks again.
711
+ */
712
+ computerUseConfig() {
713
+ if (this.config.assistant.agent !== "codex" || this.config.assistant.computerUse) return Promise.resolve([]);
714
+ this.codexOff ??= computerUseOff(this.config.assistant.binPath, this.execFn).catch((err) => {
715
+ this.codexOff = null;
716
+ throw err;
717
+ });
718
+ return this.codexOff;
719
+ }
720
+ spawnError(err) {
721
+ const code = err.code;
722
+ const bin = this.config.assistant.binPath;
723
+ if (code === "ENOENT") return `could not find ${bin}; install it or set DAILY_FOCUS_ASSISTANT_BIN to its path`;
724
+ return `could not run ${bin}: ${err instanceof Error ? err.message : String(err)}`;
725
+ }
726
+ /** The store's copy, or the repo's before `npm run init` has linked it. */
727
+ async instructions() {
728
+ for (const path of [this.config.assistantPromptFile, this.config.assistantPromptSource]) {
729
+ try {
730
+ return await readFile(path, "utf8");
731
+ } catch {
732
+ }
733
+ }
734
+ return "You are helping with one item from a personal dashboard. Read only; never send, post or edit anything.";
735
+ }
736
+ /**
737
+ * The log records what happened; the turn in memory is updated by the caller
738
+ * first. Only this process writes the file, so the memory never has to be
739
+ * re-read from it.
740
+ */
741
+ async append(record) {
742
+ await mkdir(this.config.dataDir, { recursive: true });
743
+ await appendFile(this.config.assistantLogFile, `${JSON.stringify(record)}
744
+ `, "utf8");
745
+ }
746
+ };
747
+
748
+ // src/autoask.ts
749
+ var AUTO_ACTIONS = ["assess-review", "explain-ci"];
750
+ function autoKeys(row) {
751
+ if (row.court !== "you" || row.status !== "open") return [];
752
+ const keys = [];
753
+ const heard = row.reasons.filter((reason) => reason.kind === "changes-requested" || reason.kind === "activity").map((reason) => reason.at ?? "");
754
+ if (heard.length > 0) keys.push(`assess-review@${heard.sort().at(-1) || row.updatedAt}`);
755
+ if (row.reasons.some((reason) => reason.kind === "ci-failing")) {
756
+ const commit = row.headSha ?? row.lastActivityByYou ?? row.updatedAt;
757
+ keys.push(`explain-ci@${commit}:${[...row.failingChecks].sort().join(",")}`);
758
+ }
759
+ return keys;
760
+ }
761
+ function actionOf(key) {
762
+ const action = key.slice(0, key.indexOf("@"));
763
+ return AUTO_ACTIONS.includes(action) ? action : null;
764
+ }
765
+ var SETTLE_MS = 1e3;
766
+ var AutoAsk = class {
767
+ deps;
768
+ timer = null;
769
+ /** A look is under way: one at a time, so two can't both start a turn. */
770
+ looking = false;
771
+ /** Something changed while a look was under way, so look again after it. */
772
+ again = false;
773
+ constructor(deps) {
774
+ this.deps = deps;
775
+ }
776
+ get enabled() {
777
+ return this.deps.config.assistant.auto && this.deps.assistant.enabled;
778
+ }
779
+ /** Something changed — the board, or a turn — so look again shortly. Coalesced. */
780
+ poke() {
781
+ if (!this.enabled || this.timer) return;
782
+ this.timer = setTimeout(() => {
783
+ this.timer = null;
784
+ void this.consider().catch((err) => {
785
+ console.error(`[daily-focus] could not ask ahead: ${err.message}`);
786
+ });
787
+ }, SETTLE_MS);
788
+ this.timer.unref();
789
+ }
790
+ /**
791
+ * Start the next question the board calls for, if one may start now. Returns
792
+ * its key, or null when nothing was started. Each finished turn pokes, which
793
+ * is what takes the queue on to the next.
794
+ */
795
+ async consider() {
796
+ if (!this.enabled || !this.deps.canStart()) return null;
797
+ if (this.looking) {
798
+ this.again = true;
799
+ return null;
800
+ }
801
+ this.looking = true;
802
+ try {
803
+ return await this.look();
804
+ } finally {
805
+ this.looking = false;
806
+ if (this.again) {
807
+ this.again = false;
808
+ this.poke();
809
+ }
810
+ }
811
+ }
812
+ async look() {
813
+ const { assistant } = this.deps;
814
+ await assistant.start();
815
+ const asked = assistant.autoTurns();
816
+ if (asked.some((turn) => turn.status === "running")) return null;
817
+ if (this.usedToday(asked) >= this.deps.config.assistant.autoLimit) return null;
818
+ const items = assistant.view().items;
819
+ const cutShort = (turn) => turn.status === "aborted" && (turn.error === RESTARTED || turn.error === SHUT_DOWN);
820
+ const done = new Set(asked.filter((turn) => !cutShort(turn)).map((turn) => `${turn.itemId} ${turn.auto}`));
821
+ for (const row of await this.deps.rows()) {
822
+ if (items[row.id]?.running) continue;
823
+ for (const key of autoKeys(row)) {
824
+ const action = actionOf(key);
825
+ if (!action || done.has(`${row.id} ${key}`)) continue;
826
+ try {
827
+ await assistant.ask({ itemId: row.id, action }, await this.deps.context(row.id), { auto: key });
828
+ return key;
829
+ } catch {
830
+ return null;
831
+ }
832
+ }
833
+ }
834
+ return null;
835
+ }
836
+ /** Asking ahead as the page shows it, for the board's rows as they are now. */
837
+ view(rows) {
838
+ const keyed = {};
839
+ for (const row of rows) {
840
+ const keys = autoKeys(row);
841
+ if (keys.length > 0) keyed[row.id] = keys;
842
+ }
843
+ return {
844
+ enabled: this.enabled,
845
+ limit: this.deps.config.assistant.autoLimit,
846
+ usedToday: this.usedToday(this.deps.assistant.autoTurns()),
847
+ rows: keyed
848
+ };
849
+ }
850
+ close() {
851
+ if (this.timer) clearTimeout(this.timer);
852
+ this.timer = null;
853
+ }
854
+ /** Turns started on its own today, on the server's calendar, however they went. */
855
+ usedToday(asked) {
856
+ const today = localDateKey(this.deps.now?.() ?? /* @__PURE__ */ new Date());
857
+ return asked.filter((turn) => localDateKey(new Date(turn.startedAt)) === today).length;
858
+ }
859
+ };
860
+
861
+ // src/prs.ts
862
+ var NUDGE_AFTER_MS = 24 * 36e5;
863
+ var STALE_DRAFT_AFTER_MS = 14 * 864e5;
864
+ var COURT_ORDER = ["you", "ready", "reviewers", "gate", "blocked", "checks", "draft"];
865
+ function courtOrder(court) {
866
+ return COURT_ORDER.indexOf(court);
867
+ }
868
+ function ms(iso) {
869
+ const parsed = parseISO(iso);
870
+ return parsed ? parsed.getTime() : null;
871
+ }
872
+ function deriveDecision(pr) {
873
+ if (pr.reviewDecision) return pr.reviewDecision;
874
+ if (pr.reviews.some((review) => review.state === "CHANGES_REQUESTED")) return "CHANGES_REQUESTED";
875
+ if (pr.reviews.some((review) => review.state === "APPROVED")) return "APPROVED";
876
+ return null;
877
+ }
878
+ function matchMergeGates(pr, gates) {
879
+ if (gates.length === 0) return [];
880
+ return (pr.pendingChecks ?? []).filter((check) => gates.includes(check.name));
881
+ }
882
+ function newest(pr, state) {
883
+ return pr.reviews.filter((review) => review.state === state).sort((a, b) => a.at < b.at ? 1 : -1)[0];
884
+ }
885
+ function judge(pr, now, nudgedAt, gates = []) {
886
+ const decision = deriveDecision(pr);
887
+ const you = pr.lastActivityByYou ?? null;
888
+ const others = pr.lastActivityByOthers ?? null;
889
+ const mergeState = pr.mergeStateStatus ?? null;
890
+ if (pr.isDraft) {
891
+ const touched = you ?? pr.createdAt;
892
+ const idle = now.getTime() - (ms(touched) ?? now.getTime());
893
+ return { court: "draft", decision, reasons: [], stale: idle >= STALE_DRAFT_AFTER_MS, nudge: false, since: touched };
894
+ }
895
+ const reasons = [];
896
+ if (decision === "CHANGES_REQUESTED") {
897
+ const request = newest(pr, "CHANGES_REQUESTED");
898
+ reasons.push({ kind: "changes-requested", login: request?.login, at: request?.at });
899
+ }
900
+ if (pr.checks === "failure") reasons.push({ kind: "ci-failing" });
901
+ if (pr.mergeable === "CONFLICTING" || mergeState === "DIRTY") reasons.push({ kind: "conflicts" });
902
+ if (mergeState === "BEHIND") reasons.push({ kind: "behind" });
903
+ const theirMove = others !== null && (you === null || others.at > you);
904
+ const approval = newest(pr, "APPROVED");
905
+ const afterApproval = others !== null && approval !== void 0 && others.at > approval.at;
906
+ const approved = decision === "APPROVED" && !afterApproval;
907
+ if (theirMove && others && !approved) {
908
+ reasons.push({ kind: "activity", login: others.login, at: others.at, activity: others.kind });
909
+ }
910
+ if (reasons.length > 0) {
911
+ const since = theirMove && others ? others.at : pr.updatedAt;
912
+ return { court: "you", decision, reasons, stale: false, nudge: false, since };
913
+ }
914
+ const waitingSince = laterISO(pr.readyAt, you) ?? pr.createdAt;
915
+ const settled = (court, reason) => ({
916
+ court,
917
+ decision,
918
+ reasons: [reason],
919
+ stale: false,
920
+ nudge: false,
921
+ since: waitingSince
922
+ });
923
+ if (decision !== "REVIEW_REQUIRED") {
924
+ const gate = matchMergeGates(pr, gates);
925
+ if (gate.length > 0) return settled("gate", { kind: "merge-gate", checks: gate });
926
+ const pending = pr.pendingChecks ?? [];
927
+ const running = { kind: "checks-pending" };
928
+ if (pending.length > 0) running.checks = pending;
929
+ if (mergeState === "BLOCKED" && pending.length === 0) {
930
+ return settled("blocked", { kind: "merge-blocked" });
931
+ }
932
+ if (mergeState === "BLOCKED" || mergeState === "UNSTABLE") return settled("checks", running);
933
+ if (mergeState === "UNKNOWN") return settled("checks", { kind: "mergeability-unknown" });
934
+ if (approved) {
935
+ if (mergeState === "CLEAN" || mergeState === "HAS_HOOKS") {
936
+ return { court: "ready", decision, reasons: [], stale: false, nudge: false, since: approval?.at ?? pr.updatedAt };
937
+ }
938
+ if (mergeState === null) {
939
+ const unfinished = pr.checks === "pending" || pending.length > 0;
940
+ if (!unfinished) {
941
+ return { court: "ready", decision, reasons: [], stale: false, nudge: false, since: approval?.at ?? pr.updatedAt };
942
+ }
943
+ return settled("checks", running);
944
+ }
945
+ }
946
+ }
947
+ const quietSince = laterISO(waitingSince, nudgedAt) ?? waitingSince;
948
+ const waited = now.getTime() - (ms(quietSince) ?? now.getTime());
949
+ const verdict = { court: "reviewers", decision, reasons, stale: false, nudge: waited >= NUDGE_AFTER_MS, since: waitingSince };
950
+ const nudged = ms(nudgedAt);
951
+ if (nudgedAt && nudged !== null && quietSince === nudgedAt && !verdict.nudge) {
952
+ verdict.nudgedAt = nudgedAt;
953
+ verdict.nudgedUntil = new Date(nudged + NUDGE_AFTER_MS).toISOString();
954
+ }
955
+ return verdict;
956
+ }
957
+ function resolveBoard(pulls, actions, now, gates = []) {
958
+ const folded = foldActionLog(actions, now);
959
+ const rows = pulls.map((pr) => {
960
+ const log = folded.get(canonicalId(pr.id));
961
+ const verdict = judge(pr, now, log?.nudgedAt ?? null, gates);
962
+ const row = { ...pr, ...verdict, status: log?.status === "snoozed" ? "snoozed" : "open", notes: log?.notes ?? [] };
963
+ if (row.status === "snoozed" && log?.snoozedUntil) row.snoozedUntil = log.snoozedUntil;
964
+ return row;
965
+ });
966
+ return rows.sort((a, b) => {
967
+ const court = courtOrder(a.court) - courtOrder(b.court);
968
+ if (court !== 0) return court;
969
+ if (a.since !== b.since) return a.since < b.since ? -1 : 1;
970
+ return a.id < b.id ? -1 : 1;
971
+ });
972
+ }
973
+ function countBoard(rows) {
974
+ const counts = {
975
+ you: 0,
976
+ ready: 0,
977
+ reviewers: 0,
978
+ gate: 0,
979
+ blocked: 0,
980
+ checks: 0,
981
+ draft: 0,
982
+ parked: 0
983
+ };
984
+ for (const row of rows) {
985
+ if (row.status === "snoozed") counts.parked++;
986
+ else counts[row.court]++;
987
+ }
988
+ return counts;
989
+ }
990
+
991
+ export {
992
+ CodexGate,
993
+ codexVersionWarning,
994
+ QUICK_ACTIONS,
995
+ ADAPTERS,
996
+ Assistant,
997
+ autoKeys,
998
+ actionOf,
999
+ AutoAsk,
1000
+ judge,
1001
+ resolveBoard,
1002
+ countBoard
1003
+ };