daily-focus 1.2.0-dev.8 → 2.0.0-dev.14

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,17 +1,33 @@
1
1
  import {
2
2
  ADAPTERS,
3
3
  Assistant,
4
+ AutoAsk,
4
5
  CodexGate,
5
- codexVersionWarning
6
- } from "./chunk-SX5FPS3A.js";
6
+ HANDOFF,
7
+ WAIT,
8
+ codexLatestModel,
9
+ codexVersionWarning,
10
+ compactArgs,
11
+ computerUseOff,
12
+ connectorLabel,
13
+ countBoard,
14
+ refusalFor,
15
+ resolveBoard,
16
+ shorten,
17
+ unwrapShell
18
+ } from "./chunk-JTMBBUMD.js";
7
19
  import {
8
20
  FOCUS_TEMPLATE,
21
+ SETTINGS,
22
+ applySettingsChange,
23
+ describeSettings,
24
+ saveSettings,
9
25
  sourcesTemplate
10
- } from "./chunk-V2UN3JYL.js";
26
+ } from "./chunk-U6EPOXEW.js";
11
27
  import {
12
28
  TimeZoneKeeper,
13
29
  validZone
14
- } from "./chunk-6SMUSPZG.js";
30
+ } from "./chunk-YPGKXTBX.js";
15
31
  import {
16
32
  describeLink,
17
33
  linkStore,
@@ -21,26 +37,27 @@ import {
21
37
  ACTION_TYPES,
22
38
  Store,
23
39
  canonicalId,
24
- foldActionLog,
40
+ foldActionLog
41
+ } from "./chunk-ITUK7XKS.js";
42
+ import {
25
43
  tempPathFor,
26
44
  writeJsonAtomic
27
- } from "./chunk-GQ3NCWRT.js";
45
+ } from "./chunk-QG4XXKBP.js";
28
46
  import {
29
47
  CLI_NAMES,
30
- LAUNCH_SETTINGS,
31
48
  PROFILES,
32
49
  briefWritingTools,
33
50
  describeTime,
34
- envSources,
51
+ expandHome,
35
52
  laterISO,
36
- layerEnv,
53
+ layerSettings,
37
54
  loadConfig,
38
55
  nextScheduledRun,
39
- parseISO,
40
- readDotEnv,
56
+ oldDotenvWarning,
41
57
  resolveSchedule,
42
- scheduledRunDue
43
- } from "./chunk-KZNY45XW.js";
58
+ scheduledRunDue,
59
+ settingSources
60
+ } from "./chunk-72NUDSOV.js";
44
61
  import {
45
62
  PACKAGED,
46
63
  command,
@@ -52,19 +69,259 @@ import {
52
69
  import { createServer } from "node:http";
53
70
  import { createReadStream } from "node:fs";
54
71
  import { stat as stat4 } from "node:fs/promises";
55
- import { basename, extname, join as join4, normalize, resolve, sep } from "node:path";
72
+ import { basename, extname, join as join7, normalize, resolve, sep } from "node:path";
56
73
  import { fileURLToPath } from "node:url";
57
74
 
75
+ // src/clis.ts
76
+ import { execFile as execFile2 } from "node:child_process";
77
+ import { homedir } from "node:os";
78
+ import { join as join2 } from "node:path";
79
+
80
+ // src/setup.ts
81
+ import { execFile } from "node:child_process";
82
+ import { access, constants, readdir, readFile, stat } from "node:fs/promises";
83
+ import { delimiter, isAbsolute, join } from "node:path";
84
+ import { promisify } from "node:util";
85
+ var run = promisify(execFile);
86
+ var PLACEHOLDERS = [
87
+ "Your Name",
88
+ "you@example.com",
89
+ "you@personal.example",
90
+ "your-github-login",
91
+ "Some Shared Calendar",
92
+ "Some Noisy Calendar",
93
+ "<where your colleagues are>",
94
+ "<where you are>",
95
+ "<where you live>",
96
+ "<document id>"
97
+ ];
98
+ function placeholdersIn(text) {
99
+ return PLACEHOLDERS.filter((placeholder) => text.includes(placeholder)).length;
100
+ }
101
+ async function findExecutable(name, path = process.env.PATH ?? "") {
102
+ const candidates = isAbsolute(name) ? [name] : path.split(delimiter).filter(Boolean).map((dir) => join(dir, name));
103
+ for (const candidate of candidates) {
104
+ try {
105
+ await access(candidate, constants.X_OK);
106
+ if ((await stat(candidate)).isFile()) return candidate;
107
+ } catch {
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+ async function findClis(config) {
113
+ const found = {};
114
+ for (const cli2 of CLI_NAMES) {
115
+ const candidates = [
116
+ config.agent.cli === cli2 ? config.agent.binPath : null,
117
+ config.assistant.agent === cli2 ? config.assistant.binPath : null,
118
+ cli2
119
+ ].filter((candidate) => candidate !== null);
120
+ found[cli2] = null;
121
+ for (const candidate of candidates) {
122
+ found[cli2] = await findExecutable(candidate);
123
+ if (found[cli2]) break;
124
+ }
125
+ }
126
+ return found;
127
+ }
128
+ async function hasArchive(config) {
129
+ try {
130
+ return (await readdir(config.archiveDir)).some((name) => name.endsWith(".json"));
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+ async function exists(path) {
136
+ try {
137
+ await stat(path);
138
+ return true;
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
143
+ async function firstBriefPending(config) {
144
+ return !await exists(config.itemsFile) && !await hasArchive(config);
145
+ }
146
+ async function setupState(config) {
147
+ let sources = null;
148
+ try {
149
+ sources = await readFile(config.sourcesFile, "utf8");
150
+ } catch {
151
+ sources = null;
152
+ }
153
+ const supported = process.platform === "darwin";
154
+ return {
155
+ // The file, not whether it parsed: a brief that is there but broken is a
156
+ // problem for its banner to report, not a store to set up from scratch. The
157
+ // agent's clock asks the same question.
158
+ needed: await firstBriefPending(config),
159
+ profile: config.profile,
160
+ sources: { exists: sources !== null, placeholders: sources === null ? 0 : placeholdersIn(sources) },
161
+ clis: await findClis(config),
162
+ calendar: {
163
+ supported,
164
+ built: supported && await exists(config.calendar.appPath),
165
+ chosen: config.calendar.names.length,
166
+ buildCommand: command("build-calendar")
167
+ }
168
+ };
169
+ }
170
+ var identity = null;
171
+ function ghLogins(json) {
172
+ try {
173
+ const hosts = JSON.parse(json).hosts ?? {};
174
+ const accounts = Array.isArray(hosts["github.com"]) ? hosts["github.com"] : [];
175
+ return accounts.filter((account) => typeof account.login === "string" && account.state !== "error").sort((a, b) => Number(b.active === true) - Number(a.active === true)).map((account) => account.login);
176
+ } catch {
177
+ return [];
178
+ }
179
+ }
180
+ function detectIdentity(config) {
181
+ identity ??= (async () => {
182
+ const [user, logins, email] = await Promise.all([
183
+ run(config.github.ghPath, ["api", "user", "--jq", '[.login, .name // ""] | join("\\n")'], { timeout: 5e3 }).then(
184
+ ({ stdout }) => stdout.split("\n"),
185
+ () => []
186
+ ),
187
+ run(config.github.ghPath, ["auth", "status", "--json", "hosts"], { timeout: 5e3 }).then(
188
+ ({ stdout }) => ghLogins(stdout),
189
+ () => []
190
+ ),
191
+ run("git", ["config", "--global", "user.email"], { timeout: 2e3 }).then(
192
+ ({ stdout }) => stdout.trim() || null,
193
+ () => null
194
+ )
195
+ ]);
196
+ const login = config.github.accounts[0] ?? (user[0]?.trim() || null);
197
+ return {
198
+ login,
199
+ logins: logins.length ? logins : login ? [login] : [],
200
+ name: user[1]?.trim() || null,
201
+ email
202
+ };
203
+ })();
204
+ return identity;
205
+ }
206
+ function fillIdentity(template, who) {
207
+ let text = template;
208
+ if (who.name) text = text.replaceAll("`Your Name`", `\`${who.name}\``);
209
+ if (who.email) text = text.replaceAll("`you@example.com`", `\`${who.email}\``).replaceAll("`you@personal.example`", `\`${who.email}\``);
210
+ if (who.login) text = text.replaceAll("`your-github-login`", `\`${who.login}\``);
211
+ return text;
212
+ }
213
+
214
+ // src/clis.ts
215
+ var CLI_PREFERENCE = ["codex", "claude"];
216
+ var PROBE_TIMEOUT_MS = 1e4;
217
+ var defaultProbe = (file, args) => new Promise((resolve2, reject) => {
218
+ execFile2(file, [...args], { timeout: PROBE_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
219
+ const code = err ? typeof err.code === "number" ? err.code : null : 0;
220
+ if (err && err.code === "ENOENT") return reject(err);
221
+ resolve2({ code, out: `${stdout}
222
+ ${stderr}`.trim() });
223
+ });
224
+ });
225
+ var CLI_LABEL = { claude: "Claude Code", codex: "Codex" };
226
+ function knownLocations(cli2, home = homedir()) {
227
+ const common = [join2(home, ".local", "bin", cli2), `/opt/homebrew/bin/${cli2}`, `/usr/local/bin/${cli2}`];
228
+ return cli2 === "codex" ? [join2(home, ".codex", "packages", "standalone", "current", "bin", "codex"), ...common] : [...common, join2(home, ".claude", "local", "claude")];
229
+ }
230
+ var VERSION = /(\d+\.\d+\.\d+)/;
231
+ function identify(output) {
232
+ if (/claude code/i.test(output)) return "claude";
233
+ if (/\bcodex(-cli)?\b/i.test(output)) return "codex";
234
+ return null;
235
+ }
236
+ function loggedIn(cli2, result) {
237
+ if (cli2 === "claude") {
238
+ const json = /\{[\s\S]*\}/.exec(result.out)?.[0];
239
+ try {
240
+ const status = json ? JSON.parse(json) : null;
241
+ return typeof status?.loggedIn === "boolean" ? status.loggedIn : null;
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
246
+ if (/not logged in|logged out/i.test(result.out)) return false;
247
+ if (/logged in/i.test(result.out)) return true;
248
+ return result.code !== null && result.code !== 0 ? false : null;
249
+ }
250
+ var LOGIN = {
251
+ claude: { args: ["auth", "status"], fix: "claude auth login" },
252
+ codex: { args: ["login", "status"], fix: "codex login" }
253
+ };
254
+ async function probeCli(path, expected, onPath, probe = defaultProbe) {
255
+ const missing = (problem) => ({
256
+ cli: expected,
257
+ found: { path, version: null, onPath, ready: false, problem, fix: null }
258
+ });
259
+ let version;
260
+ try {
261
+ version = await probe(path, ["--version"]);
262
+ } catch {
263
+ return missing(`Nothing runs at ${path}`);
264
+ }
265
+ const cli2 = identify(version.out) ?? expected;
266
+ if (version.code !== 0 || !cli2) {
267
+ const said = version.out.split("\n")[0]?.slice(0, 120) || "nothing";
268
+ return missing(cli2 ? `${path} didn't say its version` : `${path} isn't Codex or Claude Code: it says \u201C${said}\u201D`);
269
+ }
270
+ const number = VERSION.exec(version.out)?.[1] ?? null;
271
+ const tooOld = cli2 === "codex" ? refusalFor(number) : null;
272
+ if (tooOld) {
273
+ return { cli: cli2, found: { path, version: number, onPath, ready: false, problem: tooOld, fix: "codex update" } };
274
+ }
275
+ let login = null;
276
+ try {
277
+ login = loggedIn(cli2, await probe(path, LOGIN[cli2].args));
278
+ } catch {
279
+ login = null;
280
+ }
281
+ return {
282
+ cli: cli2,
283
+ found: login === false ? { path, version: number, onPath, ready: false, problem: `${CLI_LABEL[cli2]} is installed but not logged in`, fix: LOGIN[cli2].fix } : { path, version: number, onPath, ready: true, problem: null, fix: null }
284
+ };
285
+ }
286
+ async function detectClis(configured = {}, opts = {}) {
287
+ const report = {};
288
+ await Promise.all(
289
+ CLI_NAMES.map(async (cli2) => {
290
+ const onPath = await findExecutable(cli2, opts.path);
291
+ const candidates = [
292
+ ...configured[cli2] ? [await findExecutable(configured[cli2], opts.path)] : [],
293
+ onPath,
294
+ ...await Promise.all((opts.locations ?? knownLocations)(cli2).map((location) => findExecutable(location)))
295
+ ];
296
+ const seen = /* @__PURE__ */ new Set();
297
+ for (const candidate of candidates) {
298
+ if (!candidate || seen.has(candidate)) continue;
299
+ seen.add(candidate);
300
+ const { found } = await probeCli(candidate, cli2, candidate === onPath, opts.probe);
301
+ if (found.version !== null || found.ready) {
302
+ report[cli2] = found;
303
+ return;
304
+ }
305
+ }
306
+ report[cli2] = { path: null, version: null, onPath: false, ready: false, problem: `${CLI_LABEL[cli2]} isn't installed`, fix: null };
307
+ })
308
+ );
309
+ return report;
310
+ }
311
+ function preferredCli(report) {
312
+ return CLI_PREFERENCE.find((cli2) => report[cli2].ready) ?? null;
313
+ }
314
+
58
315
  // src/editable.ts
59
316
  import { createHash } from "node:crypto";
60
- import { readFile, rename, writeFile } from "node:fs/promises";
317
+ import { readFile as readFile2, rename, writeFile } from "node:fs/promises";
61
318
  var ABSENT = "absent";
62
319
  function versionOf(text) {
63
320
  return text === null ? ABSENT : createHash("sha256").update(text).digest("hex").slice(0, 16);
64
321
  }
65
322
  async function readEditable(path) {
66
323
  try {
67
- const text = await readFile(path, "utf8");
324
+ const text = await readFile2(path, "utf8");
68
325
  return { text, version: versionOf(text) };
69
326
  } catch (error) {
70
327
  if (error.code === "ENOENT") return { text: null, version: ABSENT };
@@ -125,7 +382,7 @@ ${lines.join("\n")}
125
382
  }
126
383
 
127
384
  // src/board.ts
128
- import { readFile as readFile2 } from "node:fs/promises";
385
+ import { readFile as readFile3 } from "node:fs/promises";
129
386
 
130
387
  // src/failure.ts
131
388
  var MAX_DETAIL = 4e3;
@@ -219,9 +476,9 @@ function reasonFor(code) {
219
476
  }
220
477
 
221
478
  // src/github.ts
222
- import { execFile } from "node:child_process";
223
- import { promisify } from "node:util";
224
- var run = promisify(execFile);
479
+ import { execFile as execFile3 } from "node:child_process";
480
+ import { promisify as promisify2 } from "node:util";
481
+ var run2 = promisify2(execFile3);
225
482
  var GH_TIMEOUT_MS = 15e3;
226
483
  var FETCH_TIMEOUT_MS = 3e4;
227
484
  var GRAPHQL_URL = "https://api.github.com/graphql";
@@ -244,7 +501,7 @@ var GhAuthError = class extends Error {
244
501
  };
245
502
  async function gh(ghPath, args) {
246
503
  try {
247
- const { stdout, stderr } = await run(ghPath, args, { env: GH_ENV, timeout: GH_TIMEOUT_MS, maxBuffer: 1 << 20 });
504
+ const { stdout, stderr } = await run2(ghPath, args, { env: GH_ENV, timeout: GH_TIMEOUT_MS, maxBuffer: 1 << 20 });
248
505
  return { stdout, stderr, code: 0 };
249
506
  } catch (err) {
250
507
  const failure = err;
@@ -815,6 +1072,7 @@ async function fetchPulls(token, scope, expectedLogin, signal) {
815
1072
  for (const [nodeId, head] of checks.heads) {
816
1073
  const pull = found.get(nodeId);
817
1074
  if (!pull) continue;
1075
+ pull.headSha = head.oid;
818
1076
  applyChecks(pull, head.rollup);
819
1077
  if (head.rollup && rollupTruncated(head.rollup)) {
820
1078
  partial.push({ id: nodeId, pull, oid: head.oid, rollup: head.rollup });
@@ -949,136 +1207,6 @@ async function fetchTicketPulls(token, scope, closedSince, signal) {
949
1207
  return { pulls, complete };
950
1208
  }
951
1209
 
952
- // src/prs.ts
953
- var NUDGE_AFTER_MS = 24 * 36e5;
954
- var STALE_DRAFT_AFTER_MS = 14 * 864e5;
955
- var COURT_ORDER = ["you", "ready", "reviewers", "gate", "blocked", "checks", "draft"];
956
- function courtOrder(court) {
957
- return COURT_ORDER.indexOf(court);
958
- }
959
- function ms(iso) {
960
- const parsed = parseISO(iso);
961
- return parsed ? parsed.getTime() : null;
962
- }
963
- function deriveDecision(pr) {
964
- if (pr.reviewDecision) return pr.reviewDecision;
965
- if (pr.reviews.some((review) => review.state === "CHANGES_REQUESTED")) return "CHANGES_REQUESTED";
966
- if (pr.reviews.some((review) => review.state === "APPROVED")) return "APPROVED";
967
- return null;
968
- }
969
- function matchMergeGates(pr, gates) {
970
- if (gates.length === 0) return [];
971
- return (pr.pendingChecks ?? []).filter((check) => gates.includes(check.name));
972
- }
973
- function newest(pr, state) {
974
- return pr.reviews.filter((review) => review.state === state).sort((a, b) => a.at < b.at ? 1 : -1)[0];
975
- }
976
- function judge(pr, now, nudgedAt, gates = []) {
977
- const decision = deriveDecision(pr);
978
- const you = pr.lastActivityByYou ?? null;
979
- const others = pr.lastActivityByOthers ?? null;
980
- const mergeState = pr.mergeStateStatus ?? null;
981
- if (pr.isDraft) {
982
- const touched = you ?? pr.createdAt;
983
- const idle = now.getTime() - (ms(touched) ?? now.getTime());
984
- return { court: "draft", decision, reasons: [], stale: idle >= STALE_DRAFT_AFTER_MS, nudge: false, since: touched };
985
- }
986
- const reasons = [];
987
- if (decision === "CHANGES_REQUESTED") {
988
- const request = newest(pr, "CHANGES_REQUESTED");
989
- reasons.push({ kind: "changes-requested", login: request?.login, at: request?.at });
990
- }
991
- if (pr.checks === "failure") reasons.push({ kind: "ci-failing" });
992
- if (pr.mergeable === "CONFLICTING" || mergeState === "DIRTY") reasons.push({ kind: "conflicts" });
993
- if (mergeState === "BEHIND") reasons.push({ kind: "behind" });
994
- const theirMove = others !== null && (you === null || others.at > you);
995
- const approval = newest(pr, "APPROVED");
996
- const afterApproval = others !== null && approval !== void 0 && others.at > approval.at;
997
- const approved = decision === "APPROVED" && !afterApproval;
998
- if (theirMove && others && !approved) {
999
- reasons.push({ kind: "activity", login: others.login, at: others.at, activity: others.kind });
1000
- }
1001
- if (reasons.length > 0) {
1002
- const since = theirMove && others ? others.at : pr.updatedAt;
1003
- return { court: "you", decision, reasons, stale: false, nudge: false, since };
1004
- }
1005
- const waitingSince = laterISO(pr.readyAt, you) ?? pr.createdAt;
1006
- const settled = (court, reason) => ({
1007
- court,
1008
- decision,
1009
- reasons: [reason],
1010
- stale: false,
1011
- nudge: false,
1012
- since: waitingSince
1013
- });
1014
- if (decision !== "REVIEW_REQUIRED") {
1015
- const gate = matchMergeGates(pr, gates);
1016
- if (gate.length > 0) return settled("gate", { kind: "merge-gate", checks: gate });
1017
- const pending = pr.pendingChecks ?? [];
1018
- const running = { kind: "checks-pending" };
1019
- if (pending.length > 0) running.checks = pending;
1020
- if (mergeState === "BLOCKED" && pending.length === 0) {
1021
- return settled("blocked", { kind: "merge-blocked" });
1022
- }
1023
- if (mergeState === "BLOCKED" || mergeState === "UNSTABLE") return settled("checks", running);
1024
- if (mergeState === "UNKNOWN") return settled("checks", { kind: "mergeability-unknown" });
1025
- if (approved) {
1026
- if (mergeState === "CLEAN" || mergeState === "HAS_HOOKS") {
1027
- return { court: "ready", decision, reasons: [], stale: false, nudge: false, since: approval?.at ?? pr.updatedAt };
1028
- }
1029
- if (mergeState === null) {
1030
- const unfinished = pr.checks === "pending" || pending.length > 0;
1031
- if (!unfinished) {
1032
- return { court: "ready", decision, reasons: [], stale: false, nudge: false, since: approval?.at ?? pr.updatedAt };
1033
- }
1034
- return settled("checks", running);
1035
- }
1036
- }
1037
- }
1038
- const quietSince = laterISO(waitingSince, nudgedAt) ?? waitingSince;
1039
- const waited = now.getTime() - (ms(quietSince) ?? now.getTime());
1040
- const verdict = { court: "reviewers", decision, reasons, stale: false, nudge: waited >= NUDGE_AFTER_MS, since: waitingSince };
1041
- const nudged = ms(nudgedAt);
1042
- if (nudgedAt && nudged !== null && quietSince === nudgedAt && !verdict.nudge) {
1043
- verdict.nudgedAt = nudgedAt;
1044
- verdict.nudgedUntil = new Date(nudged + NUDGE_AFTER_MS).toISOString();
1045
- }
1046
- return verdict;
1047
- }
1048
- function resolveBoard(pulls, actions, now, gates = []) {
1049
- const folded = foldActionLog(actions, now);
1050
- const rows = pulls.map((pr) => {
1051
- const log = folded.get(canonicalId(pr.id));
1052
- const verdict = judge(pr, now, log?.nudgedAt ?? null, gates);
1053
- const row = { ...pr, ...verdict, status: log?.status === "snoozed" ? "snoozed" : "open", notes: log?.notes ?? [] };
1054
- if (row.status === "snoozed" && log?.snoozedUntil) row.snoozedUntil = log.snoozedUntil;
1055
- return row;
1056
- });
1057
- return rows.sort((a, b) => {
1058
- const court = courtOrder(a.court) - courtOrder(b.court);
1059
- if (court !== 0) return court;
1060
- if (a.since !== b.since) return a.since < b.since ? -1 : 1;
1061
- return a.id < b.id ? -1 : 1;
1062
- });
1063
- }
1064
- function countBoard(rows) {
1065
- const counts = {
1066
- you: 0,
1067
- ready: 0,
1068
- reviewers: 0,
1069
- gate: 0,
1070
- blocked: 0,
1071
- checks: 0,
1072
- draft: 0,
1073
- parked: 0
1074
- };
1075
- for (const row of rows) {
1076
- if (row.status === "snoozed") counts.parked++;
1077
- else counts[row.court]++;
1078
- }
1079
- return counts;
1080
- }
1081
-
1082
1210
  // src/board.ts
1083
1211
  var MAX_BACKOFF_MS = 30 * 6e4;
1084
1212
  var RATE_LIMIT_FLOOR = 100;
@@ -1305,7 +1433,7 @@ ${details[i] || "(nothing more)"}`).join("\n\n");
1305
1433
  let lowestRemaining = null;
1306
1434
  const results = await Promise.allSettled(
1307
1435
  identities.map(
1308
- (identity2) => this.#deps.fetch(identity2.token, this.#config.github.scope, identity2.account.login, this.#abort.signal)
1436
+ (identity3) => this.#deps.fetch(identity3.token, this.#config.github.scope, identity3.account.login, this.#abort.signal)
1309
1437
  )
1310
1438
  );
1311
1439
  results.forEach((result, i) => {
@@ -1440,6 +1568,9 @@ function normalizeStoredPull(raw) {
1440
1568
  autoMerge: raw.autoMerge === true,
1441
1569
  requestedReviewers: strings(raw.requestedReviewers),
1442
1570
  reviews,
1571
+ // Kept, or a restart would hand the assistant's answers ahead about failing
1572
+ // checks a key the live read doesn't, and they would be asked again.
1573
+ headSha: typeof raw.headSha === "string" ? raw.headSha : null,
1443
1574
  lastActivityByYou: typeof raw.lastActivityByYou === "string" ? raw.lastActivityByYou : null,
1444
1575
  lastActivityByOthers
1445
1576
  };
@@ -1447,7 +1578,7 @@ function normalizeStoredPull(raw) {
1447
1578
  async function readPullsFile(path) {
1448
1579
  let text;
1449
1580
  try {
1450
- text = await readFile2(path, "utf8");
1581
+ text = await readFile3(path, "utf8");
1451
1582
  } catch {
1452
1583
  return null;
1453
1584
  }
@@ -1471,12 +1602,12 @@ async function readPullsFile(path) {
1471
1602
  }
1472
1603
 
1473
1604
  // src/calendar.ts
1474
- import { execFile as execFile2 } from "node:child_process";
1475
- import { readFile as readFile3, rm, stat } from "node:fs/promises";
1605
+ import { execFile as execFile4 } from "node:child_process";
1606
+ import { readFile as readFile4, rm, stat as stat2 } from "node:fs/promises";
1476
1607
  import { tmpdir } from "node:os";
1477
- import { join } from "node:path";
1478
- import { promisify as promisify2 } from "node:util";
1479
- var run2 = promisify2(execFile2);
1608
+ import { join as join3 } from "node:path";
1609
+ import { promisify as promisify3 } from "node:util";
1610
+ var run3 = promisify3(execFile4);
1480
1611
  var HELPER_TIMEOUT_MS = 2e4;
1481
1612
  var POLL_MS = 150;
1482
1613
  var DECLINED = 3;
@@ -1499,17 +1630,17 @@ function parseCalendarFacts(raw) {
1499
1630
  if (typeof raw !== "object" || raw === null) return null;
1500
1631
  const obj = raw;
1501
1632
  if (str2(obj.error)) return null;
1502
- const calendars = [];
1633
+ const calendars2 = [];
1503
1634
  if (Array.isArray(obj.calendars)) {
1504
1635
  for (const entry of obj.calendars) {
1505
1636
  const id = str2(entry?.id);
1506
1637
  const title = str2(entry?.title);
1507
- if (id && title) calendars.push({ id, title, source: str2(entry?.source) ?? "" });
1638
+ if (id && title) calendars2.push({ id, title, source: str2(entry?.source) ?? "" });
1508
1639
  }
1509
1640
  }
1510
1641
  return {
1511
1642
  generatedAt: str2(obj.generatedAt) ?? (/* @__PURE__ */ new Date(0)).toISOString(),
1512
- calendars,
1643
+ calendars: calendars2,
1513
1644
  events: Array.isArray(obj.events) ? obj.events : []
1514
1645
  };
1515
1646
  }
@@ -1566,17 +1697,17 @@ var CalendarHelperError = class extends Error {
1566
1697
  };
1567
1698
  async function runHelper(appPath, selfAddresses) {
1568
1699
  try {
1569
- await stat(appPath);
1700
+ await stat2(appPath);
1570
1701
  } catch {
1571
1702
  throw new CalendarHelperError(
1572
1703
  `the calendar helper isn't built at ${appPath} \u2014 run ${command("build-calendar")}, or point DAILY_FOCUS_CALENDAR_APP at an existing build`
1573
1704
  );
1574
1705
  }
1575
- const out = join(tmpdir(), `daily-focus-calendar-${process.pid}-${Date.now()}.json`);
1706
+ const out = join3(tmpdir(), `daily-focus-calendar-${process.pid}-${Date.now()}.json`);
1576
1707
  try {
1577
1708
  try {
1578
1709
  const zone = process.env.TZ ? ["--env", `TZ=${process.env.TZ}`] : [];
1579
- await run2("/usr/bin/open", ["-n", "-a", appPath, ...zone, "--args", out, selfAddresses.join(",")], {
1710
+ await run3("/usr/bin/open", ["-n", "-a", appPath, ...zone, "--args", out, selfAddresses.join(",")], {
1580
1711
  timeout: HELPER_TIMEOUT_MS
1581
1712
  });
1582
1713
  } catch (err) {
@@ -1587,7 +1718,7 @@ async function runHelper(appPath, selfAddresses) {
1587
1718
  for (; ; ) {
1588
1719
  let text = null;
1589
1720
  try {
1590
- text = await readFile3(out, "utf8");
1721
+ text = await readFile4(out, "utf8");
1591
1722
  } catch {
1592
1723
  text = null;
1593
1724
  }
@@ -1611,7 +1742,7 @@ async function runHelper(appPath, selfAddresses) {
1611
1742
  }
1612
1743
 
1613
1744
  // src/calendarboard.ts
1614
- import { readFile as readFile4 } from "node:fs/promises";
1745
+ import { readFile as readFile5 } from "node:fs/promises";
1615
1746
  var MAX_BACKOFF_MS2 = 15 * 6e4;
1616
1747
  var realDeps2 = { run: runHelper };
1617
1748
  var CalendarBoard = class {
@@ -1763,18 +1894,18 @@ var CalendarBoard = class {
1763
1894
  };
1764
1895
  async function readCalendarFile(path) {
1765
1896
  try {
1766
- return parseCalendarFacts(JSON.parse(await readFile4(path, "utf8")));
1897
+ return parseCalendarFacts(JSON.parse(await readFile5(path, "utf8")));
1767
1898
  } catch {
1768
1899
  return null;
1769
1900
  }
1770
1901
  }
1771
1902
 
1772
1903
  // src/ticketboard.ts
1773
- import { readFile as readFile5 } from "node:fs/promises";
1904
+ import { readFile as readFile6 } from "node:fs/promises";
1774
1905
 
1775
1906
  // src/jira.ts
1776
- import { execFile as execFile3 } from "node:child_process";
1777
- import { promisify as promisify3 } from "node:util";
1907
+ import { execFile as execFile5 } from "node:child_process";
1908
+ import { promisify as promisify4 } from "node:util";
1778
1909
 
1779
1910
  // src/linked.ts
1780
1911
  var CLOSED_LOOKBACK_DAYS = 60;
@@ -1856,7 +1987,7 @@ function reconcile(ticket, linked, jiraCount) {
1856
1987
  }
1857
1988
 
1858
1989
  // src/jira.ts
1859
- var run3 = promisify3(execFile3);
1990
+ var run4 = promisify4(execFile5);
1860
1991
  var ACLI_TIMEOUT_MS = 45e3;
1861
1992
  var PAGE_LIMIT = 200;
1862
1993
  var ACLI_ENV = { ...process.env, NO_COLOR: "1" };
@@ -1892,7 +2023,7 @@ var JiraTransitionError = class extends Error {
1892
2023
  };
1893
2024
  async function acli(acliPath, args) {
1894
2025
  try {
1895
- const { stdout, stderr } = await run3(acliPath, args, {
2026
+ const { stdout, stderr } = await run4(acliPath, args, {
1896
2027
  env: ACLI_ENV,
1897
2028
  timeout: ACLI_TIMEOUT_MS,
1898
2029
  // A hundred tickets of raw Jira issue JSON measured 135 KB; this is room
@@ -2038,13 +2169,13 @@ function jsonObjectAt(text, from) {
2038
2169
  }
2039
2170
  return null;
2040
2171
  }
2041
- function readDevPullRequests(field) {
2042
- if (typeof field !== "string") return null;
2043
- const marker = field.indexOf("json=");
2172
+ function readDevPullRequests(field2) {
2173
+ if (typeof field2 !== "string") return null;
2174
+ const marker = field2.indexOf("json=");
2044
2175
  if (marker === -1) return null;
2045
- const opens = field.indexOf("{", marker);
2176
+ const opens = field2.indexOf("{", marker);
2046
2177
  if (opens === -1) return null;
2047
- const body = jsonObjectAt(field, opens);
2178
+ const body = jsonObjectAt(field2, opens);
2048
2179
  if (body === null) return null;
2049
2180
  let parsed;
2050
2181
  try {
@@ -2174,19 +2305,19 @@ async function fetchTickets(acliPath, opts) {
2174
2305
  const undecided = new Set(reconciled.filter((ticket) => !ticket.hasOpenPr && !ticket.allPrsClosed).map((ticket) => ticket.key));
2175
2306
  const unansweredNow = unanswered.filter((key) => undecided.has(key));
2176
2307
  const unaccountedNow = unaccounted.filter((key) => undecided.has(key));
2177
- const listed = (keys2) => keys2.map((key) => {
2308
+ const listed2 = (keys2) => keys2.map((key) => {
2178
2309
  const url = browseUrl(opts.site, key);
2179
2310
  return url === null ? key : `[${key}](${url})`;
2180
2311
  }).join(", ");
2181
2312
  const heldOut = (keys2) => `${keys2.length === 1 ? "it is" : "they are"} held out of "no open pull requests left" rather than guessed at`;
2182
2313
  if (unansweredNow.length > 0) {
2183
2314
  warnings.push(
2184
- `Could not read Jira's development panel for ${listed(unansweredNow)}, so ${heldOut(unansweredNow)}. The next read may well manage it.`
2315
+ `Could not read Jira's development panel for ${listed2(unansweredNow)}, so ${heldOut(unansweredNow)}. The next read may well manage it.`
2185
2316
  );
2186
2317
  }
2187
2318
  if (unaccountedNow.length > 0) {
2188
2319
  warnings.push(
2189
- `Jira's search says ${listed(unaccountedNow)} ${unaccountedNow.length === 1 ? "has" : "have"} pull requests, but its own development panel names none, so ${heldOut(unaccountedNow)}. Jira is disagreeing with itself; the row comes back when one of its caches catches up.`
2320
+ `Jira's search says ${listed2(unaccountedNow)} ${unaccountedNow.length === 1 ? "has" : "have"} pull requests, but its own development panel names none, so ${heldOut(unaccountedNow)}. Jira is disagreeing with itself; the row comes back when one of its caches catches up.`
2190
2321
  );
2191
2322
  }
2192
2323
  if (!opts.site) {
@@ -2275,16 +2406,16 @@ function readTransitionReport(stdout, key, status) {
2275
2406
  }
2276
2407
 
2277
2408
  // src/tickets.ts
2278
- var COURT_ORDER2 = ["settled", "started", "idle"];
2409
+ var COURT_ORDER = ["settled", "started", "idle"];
2279
2410
  function ticketCourtOrder(court) {
2280
- return COURT_ORDER2.indexOf(court);
2411
+ return COURT_ORDER.indexOf(court);
2281
2412
  }
2282
2413
  function inStatus(ticket, statuses) {
2283
2414
  const status = ticket.workflowStatus.trim().toLowerCase();
2284
2415
  if (status === "") return false;
2285
- return statuses.some((listed) => listed.trim().toLowerCase() === status);
2416
+ return statuses.some((listed2) => listed2.trim().toLowerCase() === status);
2286
2417
  }
2287
- function judge2(ticket, holdStatuses = []) {
2418
+ function judge(ticket, holdStatuses = []) {
2288
2419
  if (ticket.statusCategory === "done") return null;
2289
2420
  if (ticket.hasAnyPr && !ticket.hasOpenPr && ticket.allPrsClosed) return "settled";
2290
2421
  if (ticket.statusCategory === "new" && ticket.hasOpenPr) return "started";
@@ -2295,7 +2426,7 @@ function resolveTickets(tickets, actions, now, holdStatuses = []) {
2295
2426
  const folded = foldActionLog(actions, now);
2296
2427
  const rows = [];
2297
2428
  for (const ticket of tickets) {
2298
- const court = judge2(ticket, holdStatuses);
2429
+ const court = judge(ticket, holdStatuses);
2299
2430
  if (!court) continue;
2300
2431
  const log = folded.get(canonicalId(ticket.id));
2301
2432
  const row = {
@@ -2473,15 +2604,15 @@ var TicketBoard = class {
2473
2604
  }
2474
2605
  async #read() {
2475
2606
  const { acliPath, projects, site: configuredSite } = this.#config.jira;
2476
- let identity2;
2607
+ let identity3;
2477
2608
  try {
2478
- identity2 = await this.#deps.identity(acliPath);
2609
+ identity3 = await this.#deps.identity(acliPath);
2479
2610
  } catch (err) {
2480
2611
  this.#reason = { message: `${err.message}, or set DAILY_FOCUS_JIRA=off`, detail: errorDetail(err) };
2481
2612
  this.#streak = extend(this.#streak);
2482
2613
  return;
2483
2614
  }
2484
- const site = configuredSite ?? identity2.site;
2615
+ const site = configuredSite ?? identity3.site;
2485
2616
  const githubWarnings = [];
2486
2617
  const linked = this.#config.github.enabled ? this.#deps.linked(this.#config.github, /* @__PURE__ */ new Date(), AbortSignal.any([this.#abort.signal, AbortSignal.timeout(GITHUB_DEADLINE_MS)])).then(
2487
2618
  (read) => {
@@ -2506,7 +2637,7 @@ var TicketBoard = class {
2506
2637
  this.#file = {
2507
2638
  version: 1,
2508
2639
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
2509
- account: identity2.account,
2640
+ account: identity3.account,
2510
2641
  projects: [...projects],
2511
2642
  warnings,
2512
2643
  tickets,
@@ -2575,7 +2706,7 @@ function storedPulls(value) {
2575
2706
  async function readTicketsFile(path) {
2576
2707
  let text;
2577
2708
  try {
2578
- text = await readFile5(path, "utf8");
2709
+ text = await readFile6(path, "utf8");
2579
2710
  } catch {
2580
2711
  return null;
2581
2712
  }
@@ -2608,12 +2739,171 @@ async function readTicketsFile(path) {
2608
2739
  // src/agent.ts
2609
2740
  import { spawn } from "node:child_process";
2610
2741
  import { randomUUID } from "node:crypto";
2611
- import { appendFile, mkdir, readFile as readFile6 } from "node:fs/promises";
2612
- import { homedir } from "node:os";
2742
+ import { appendFile, mkdir, readFile as readFile7 } from "node:fs/promises";
2743
+ import { homedir as homedir3 } from "node:os";
2613
2744
  import { createInterface } from "node:readline";
2745
+
2746
+ // src/codexsessions.ts
2747
+ import { open, readdir as readdir2 } from "node:fs/promises";
2748
+ import { homedir as homedir2 } from "node:os";
2749
+ import { join as join4 } from "node:path";
2750
+ function codexHome(env = process.env) {
2751
+ return env.CODEX_HOME?.trim() || join4(homedir2(), ".codex");
2752
+ }
2753
+ var DAY_MS = 24 * 60 * 6e4;
2754
+ var CHUNK = 4 * 1024 * 1024;
2755
+ var CodexSessions = class {
2756
+ threads = /* @__PURE__ */ new Map();
2757
+ since;
2758
+ home;
2759
+ /** `threadId` is the run's, as the stream's `thread.started` gives it; `since`, when the run started. */
2760
+ constructor(threadId, since, home = codexHome()) {
2761
+ this.since = since;
2762
+ this.home = home;
2763
+ this.threads.set(threadId, { id: threadId, root: true, path: null, offset: 0, partial: Buffer.alloc(0) });
2764
+ }
2765
+ /**
2766
+ * Whatever the files have added since the last read, oldest first within each
2767
+ * thread. A subagent found on the way is read in the same pass.
2768
+ */
2769
+ async read(now = /* @__PURE__ */ new Date()) {
2770
+ const news = [];
2771
+ for (const thread of this.threads.values()) {
2772
+ thread.path ??= await this.find(thread.id, now);
2773
+ if (!thread.path) continue;
2774
+ for (const line of await this.tail(thread)) this.take(thread, line, news);
2775
+ }
2776
+ return news;
2777
+ }
2778
+ /**
2779
+ * The file a thread is written to. Codex files a session under the local date
2780
+ * it started, so the days the run spans are searched, a day either side for a
2781
+ * clock that disagrees.
2782
+ */
2783
+ async find(id, now) {
2784
+ const suffix = `-${id}.jsonl`;
2785
+ for (let at = this.since.getTime() - DAY_MS; at <= now.getTime() + DAY_MS; at += DAY_MS) {
2786
+ const day = new Date(at);
2787
+ const dir = join4(
2788
+ this.home,
2789
+ "sessions",
2790
+ String(day.getFullYear()),
2791
+ String(day.getMonth() + 1).padStart(2, "0"),
2792
+ String(day.getDate()).padStart(2, "0")
2793
+ );
2794
+ let names;
2795
+ try {
2796
+ names = await readdir2(dir);
2797
+ } catch {
2798
+ continue;
2799
+ }
2800
+ const name = names.find((candidate) => candidate.endsWith(suffix));
2801
+ if (name) return join4(dir, name);
2802
+ }
2803
+ return null;
2804
+ }
2805
+ /** The whole lines added to a thread's file since the last read. */
2806
+ async tail(thread) {
2807
+ let handle;
2808
+ try {
2809
+ handle = await open(thread.path, "r");
2810
+ } catch {
2811
+ return [];
2812
+ }
2813
+ try {
2814
+ const { size } = await handle.stat();
2815
+ if (size <= thread.offset) return [];
2816
+ const buffer = Buffer.alloc(Math.min(size - thread.offset, CHUNK));
2817
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, thread.offset);
2818
+ thread.offset += bytesRead;
2819
+ const bytes = Buffer.concat([thread.partial, buffer.subarray(0, bytesRead)]);
2820
+ const end = bytes.lastIndexOf(10);
2821
+ thread.partial = end === -1 ? bytes : bytes.subarray(end + 1);
2822
+ return end === -1 ? [] : bytes.subarray(0, end).toString("utf8").split("\n");
2823
+ } catch {
2824
+ return [];
2825
+ } finally {
2826
+ await handle.close();
2827
+ }
2828
+ }
2829
+ take(thread, line, news) {
2830
+ let record;
2831
+ try {
2832
+ record = JSON.parse(line);
2833
+ } catch {
2834
+ return;
2835
+ }
2836
+ if (record?.type !== "event_msg" || record.payload?.type !== "item_completed") return;
2837
+ const item = record.payload.item;
2838
+ if (typeof item !== "object" || item === null) return;
2839
+ const by = thread.root ? null : thread.id;
2840
+ if (item.type === "SubAgentActivity" && typeof item.agent_thread_id === "string") {
2841
+ const child = item.agent_thread_id;
2842
+ if (item.kind === "started") {
2843
+ if (!this.threads.has(child)) {
2844
+ this.threads.set(child, { id: child, root: false, path: null, offset: 0, partial: Buffer.alloc(0) });
2845
+ }
2846
+ news.push({ by, step: { id: child, text: HANDOFF, detail: subagentName(item.agent_path), state: "running", handoff: true } });
2847
+ } else if (item.kind === "completed") {
2848
+ news.push({ by, step: { id: child, state: "done", final: true } });
2849
+ } else if (typeof item.kind === "string" && /fail|error|abort|interrupt|cancel|kill/i.test(item.kind)) {
2850
+ news.push({ by, step: { id: child, state: "failed", final: true } });
2851
+ }
2852
+ return;
2853
+ }
2854
+ if (thread.root) return;
2855
+ if (item.type === "AgentMessage") {
2856
+ const text = Array.isArray(item.content) ? item.content.map((part) => typeof part?.text === "string" ? part.text : "").join("").trim() : "";
2857
+ if (text) news.push({ by: thread.id, message: text });
2858
+ return;
2859
+ }
2860
+ const step = rolloutStep(item);
2861
+ if (step) news.push({ by: thread.id, step: { ...step, id: `${thread.id}:${step.id}` } });
2862
+ }
2863
+ };
2864
+ function subagentName(path) {
2865
+ if (typeof path !== "string") return null;
2866
+ const name = path.split("/").filter(Boolean).at(-1);
2867
+ return name ? name.replace(/[_-]+/g, " ") : null;
2868
+ }
2869
+ function rolloutStep(item) {
2870
+ if (typeof item.id !== "string") return null;
2871
+ const failed = item.status === "failed" || item.error !== void 0 && item.error !== null;
2872
+ const state = failed ? "failed" : "done";
2873
+ switch (item.type) {
2874
+ case "CommandExecution": {
2875
+ const command3 = Array.isArray(item.command) ? item.command.length === 3 && /^-l?c$/.test(item.command[1] ?? "") ? item.command[2] : item.command.join(" ") : unwrapShell(item.command ?? "");
2876
+ const exited = typeof item.exit_code === "number" && item.exit_code !== 0;
2877
+ return { id: item.id, text: "Run a command", detail: shorten(command3), state: exited ? "failed" : state };
2878
+ }
2879
+ case "McpToolCall": {
2880
+ const app = item.server === "codex_apps" && typeof item.appName === "string" && typeof item.actionName === "string";
2881
+ const text = connectorLabel(item.server ?? "tool", app ? `${item.appName}_${item.actionName}` : item.tool ?? "call");
2882
+ return { id: item.id, text, detail: compactArgs(item.arguments), state };
2883
+ }
2884
+ case "Extension": {
2885
+ if (item.kind !== "web.search") return null;
2886
+ const action = item.action ?? {};
2887
+ if (action.type === "openPage") return { id: item.id, text: "Open a page", detail: shorten(action.url ?? null), state };
2888
+ if (action.type === "findInPage") return { id: item.id, text: "Find in a page", detail: shorten(action.pattern ?? null), state };
2889
+ return { id: item.id, text: "Search the web", detail: shorten(action.query || item.query || null), state };
2890
+ }
2891
+ case "FileChange":
2892
+ return { id: item.id, text: "Write a file", detail: shorten(Object.keys(item.changes ?? {}).join(", ") || null), state };
2893
+ case "ImageView":
2894
+ return { id: item.id, text: "Look at an image", detail: shorten(item.path?.replace(/^file:\/\//, "") ?? null), state };
2895
+ case "CollabAgentToolCall":
2896
+ return item.tool === "wait" ? { id: item.id, text: WAIT, detail: null, state } : null;
2897
+ }
2898
+ return null;
2899
+ }
2900
+
2901
+ // src/agent.ts
2614
2902
  var RUN_TIMEOUT_MS = 60 * 6e4;
2615
2903
  var TURN_TIMEOUT_MS = 10 * 6e4;
2616
2904
  var RUNS_SHOWN = 30;
2905
+ var STEPS_KEPT = 500;
2906
+ var SESSIONS_EVERY_MS = 2e3;
2617
2907
  function agentPrompt(promptPath) {
2618
2908
  return [
2619
2909
  "Run my morning brief for today.",
@@ -2629,6 +2919,11 @@ function agentPrompt(promptPath) {
2629
2919
  "Finish by reporting back as that file asks you to."
2630
2920
  ].join("\n");
2631
2921
  }
2922
+ function agentSettings(config) {
2923
+ return {
2924
+ searchTrash: config.agent.searchTrash
2925
+ };
2926
+ }
2632
2927
  function followUpPrompt(question) {
2633
2928
  return [
2634
2929
  "A follow-up question about the brief you wrote in this session. Answer it here.",
@@ -2639,7 +2934,7 @@ function followUpPrompt(question) {
2639
2934
  question.trim()
2640
2935
  ].join("\n");
2641
2936
  }
2642
- function displayPath(path, home = homedir()) {
2937
+ function displayPath(path, home = homedir3()) {
2643
2938
  return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
2644
2939
  }
2645
2940
  function foldAgentLog(text) {
@@ -2665,6 +2960,7 @@ function foldAgentLog(text) {
2665
2960
  status: "running",
2666
2961
  report: "",
2667
2962
  messages: [],
2963
+ steps: [],
2668
2964
  error: null,
2669
2965
  turns: [],
2670
2966
  resumable: false
@@ -2705,6 +3001,7 @@ function foldAgentLog(text) {
2705
3001
  run5.sessionId = typeof record.sessionId === "string" ? record.sessionId : run5.sessionId;
2706
3002
  run5.report = typeof record.report === "string" ? record.report : run5.report;
2707
3003
  run5.messages = Array.isArray(record.messages) ? record.messages.filter((message) => typeof message === "string") : run5.report ? [run5.report] : [];
3004
+ run5.steps = readSteps(record.steps);
2708
3005
  run5.endedAt = record.at;
2709
3006
  if (record.event === "finished") {
2710
3007
  run5.status = "done";
@@ -2718,6 +3015,57 @@ function foldAgentLog(text) {
2718
3015
  }
2719
3016
  return [...runs.values()].sort((a, b) => a.startedAt.localeCompare(b.startedAt));
2720
3017
  }
3018
+ function readSteps(value) {
3019
+ if (!Array.isArray(value)) return [];
3020
+ const steps = [];
3021
+ for (const candidate of value) {
3022
+ if (typeof candidate !== "object" || candidate === null || typeof candidate.text !== "string") continue;
3023
+ steps.push({
3024
+ kind: candidate.kind === "message" ? "message" : "tool",
3025
+ text: candidate.text,
3026
+ detail: typeof candidate.detail === "string" ? candidate.detail : null,
3027
+ state: candidate.state === "failed" ? "failed" : "done",
3028
+ by: typeof candidate.by === "string" ? candidate.by : null,
3029
+ ...typeof candidate.subagent === "string" ? { subagent: candidate.subagent } : {}
3030
+ });
3031
+ }
3032
+ return steps;
3033
+ }
3034
+ var StepTrail = class {
3035
+ calls = /* @__PURE__ */ new Map();
3036
+ run;
3037
+ constructor(run5) {
3038
+ this.run = run5;
3039
+ }
3040
+ said(by, text) {
3041
+ this.add({ kind: "message", text, detail: null, state: "done", by });
3042
+ }
3043
+ call(by, parsed) {
3044
+ const known = this.calls.get(parsed.id);
3045
+ if (known) {
3046
+ if (parsed.text) known.text = parsed.text;
3047
+ if (parsed.detail) known.detail = parsed.detail;
3048
+ if (!known.subagent || parsed.final || parsed.state === "failed") known.state = parsed.state;
3049
+ return;
3050
+ }
3051
+ if (!parsed.text) return;
3052
+ const step = { kind: "tool", text: parsed.text, detail: parsed.detail ?? null, state: parsed.state, by };
3053
+ if (parsed.handoff) step.subagent = parsed.id;
3054
+ this.calls.set(parsed.id, step);
3055
+ this.add(step);
3056
+ }
3057
+ /** A call nobody reported back on went the way the run did. */
3058
+ settle(ok) {
3059
+ for (const step of this.run.steps) if (step.state === "running") step.state = ok ? "done" : "failed";
3060
+ }
3061
+ add(step) {
3062
+ const steps = this.run.steps;
3063
+ steps.push(step);
3064
+ if (steps.length <= STEPS_KEPT) return;
3065
+ const oldest = steps.findIndex((candidate) => !candidate.subagent);
3066
+ steps.splice(oldest === -1 ? 0 : oldest, 1);
3067
+ }
3068
+ };
2721
3069
  var AgentRunner = class {
2722
3070
  /** Every run in the log, oldest first. */
2723
3071
  runs = [];
@@ -2728,11 +3076,14 @@ var AgentRunner = class {
2728
3076
  spawnFn;
2729
3077
  /** Refuses a Codex too old to hold to the connector rules; see `codex.ts`. */
2730
3078
  codexGate;
2731
- constructor(config, hooks, spawnFn = spawn, execFn) {
3079
+ /** Where Codex writes its session files, which say what a Codex run's subagents do. */
3080
+ codexHome;
3081
+ constructor(config, hooks, spawnFn = spawn, execFn, home = codexHome()) {
2732
3082
  this.config = config;
2733
3083
  this.hooks = hooks;
2734
3084
  this.spawnFn = spawnFn;
2735
3085
  this.codexGate = new CodexGate(config.agent.binPath, execFn);
3086
+ this.codexHome = home;
2736
3087
  }
2737
3088
  get enabled() {
2738
3089
  return this.config.agent.cli !== null;
@@ -2746,7 +3097,7 @@ var AgentRunner = class {
2746
3097
  this.loaded = true;
2747
3098
  let text = "";
2748
3099
  try {
2749
- text = await readFile6(this.config.agentLogFile, "utf8");
3100
+ text = await readFile7(this.config.agentLogFile, "utf8");
2750
3101
  } catch {
2751
3102
  return;
2752
3103
  }
@@ -2758,7 +3109,7 @@ var AgentRunner = class {
2758
3109
  run5.status = "aborted";
2759
3110
  run5.error = reason;
2760
3111
  run5.endedAt = at;
2761
- await this.append({ event: "aborted", run: run5.id, sessionId: run5.sessionId, reason, report: run5.report, messages: run5.messages, at });
3112
+ await this.append({ event: "aborted", run: run5.id, sessionId: run5.sessionId, reason, report: run5.report, messages: run5.messages, steps: run5.steps, at });
2762
3113
  }
2763
3114
  for (const turn of run5.turns) {
2764
3115
  if (turn.status !== "running") continue;
@@ -2778,7 +3129,7 @@ var AgentRunner = class {
2778
3129
  return this.runs.at(-1) ?? null;
2779
3130
  }
2780
3131
  view(now = /* @__PURE__ */ new Date()) {
2781
- const runs = this.runs.slice(-RUNS_SHOWN).reverse().map((run5) => ({ ...run5, resumable: this.resumable(run5) }));
3132
+ const runs = this.runs.slice(-RUNS_SHOWN).reverse().map((run5, index) => ({ ...run5, steps: index === 0 ? run5.steps : [], resumable: this.resumable(run5) }));
2782
3133
  return {
2783
3134
  enabled: this.enabled,
2784
3135
  cli: this.config.agent.cli,
@@ -2824,8 +3175,8 @@ var AgentRunner = class {
2824
3175
  * decided on.
2825
3176
  */
2826
3177
  async run(trigger = "hand", now = /* @__PURE__ */ new Date()) {
2827
- const cli = this.config.agent.cli;
2828
- if (!cli) throw new Error("running the morning agent is off; set DAILY_FOCUS_AGENT to codex or claude");
3178
+ const cli2 = this.config.agent.cli;
3179
+ if (!cli2) throw new Error("running the morning agent is off; set DAILY_FOCUS_AGENT to codex or claude");
2829
3180
  if (this.running) {
2830
3181
  throw new Error(
2831
3182
  this.running.turn ? "the morning agent is still answering a question" : "the morning agent is already running"
@@ -2834,7 +3185,7 @@ var AgentRunner = class {
2834
3185
  await this.start();
2835
3186
  const run5 = {
2836
3187
  id: randomUUID(),
2837
- cli,
3188
+ cli: cli2,
2838
3189
  trigger,
2839
3190
  sessionId: null,
2840
3191
  startedAt: now.toISOString(),
@@ -2842,13 +3193,20 @@ var AgentRunner = class {
2842
3193
  status: "running",
2843
3194
  report: "",
2844
3195
  messages: [],
3196
+ steps: [],
2845
3197
  error: null,
2846
3198
  turns: [],
2847
3199
  resumable: false
2848
3200
  };
2849
3201
  this.runs.push(run5);
2850
- await this.append({ event: "started", run: run5.id, cli, trigger, at: run5.startedAt });
2851
- const adapter = ADAPTERS[cli];
3202
+ await this.append({ event: "started", run: run5.id, cli: cli2, trigger, at: run5.startedAt });
3203
+ try {
3204
+ await writeJsonAtomic(this.config.agentSettingsFile, agentSettings(this.config));
3205
+ } catch (err) {
3206
+ await this.finish(run5, null, { ok: false, error: `could not write ${displayPath(this.config.agentSettingsFile)}: ${err.message}` });
3207
+ return run5;
3208
+ }
3209
+ const adapter = ADAPTERS[cli2];
2852
3210
  const args = adapter.args({
2853
3211
  prompt: agentPrompt(displayPath(this.config.promptFile)),
2854
3212
  sessionId: null,
@@ -2867,8 +3225,8 @@ var AgentRunner = class {
2867
3225
  * is running; the answer arrives through `onChange` and the next state.
2868
3226
  */
2869
3227
  async ask(runId, question) {
2870
- const cli = this.config.agent.cli;
2871
- if (!cli) throw new Error("running the morning agent is off; set DAILY_FOCUS_AGENT to codex or claude");
3228
+ const cli2 = this.config.agent.cli;
3229
+ if (!cli2) throw new Error("running the morning agent is off; set DAILY_FOCUS_AGENT to codex or claude");
2872
3230
  await this.start();
2873
3231
  const run5 = this.runs.find((candidate) => candidate.id === runId);
2874
3232
  if (!run5) throw new Error("no such run");
@@ -2879,7 +3237,7 @@ var AgentRunner = class {
2879
3237
  }
2880
3238
  if (!this.resumable(run5)) {
2881
3239
  throw new Error(
2882
- run5.sessionId === null ? "this run left no session to continue" : `this run was made by ${run5.cli}, and the agent is ${cli} now`
3240
+ run5.sessionId === null ? "this run left no session to continue" : `this run was made by ${run5.cli}, and the agent is ${cli2} now`
2883
3241
  );
2884
3242
  }
2885
3243
  const text = question.trim();
@@ -2895,7 +3253,7 @@ var AgentRunner = class {
2895
3253
  };
2896
3254
  run5.turns.push(turn);
2897
3255
  await this.append({ event: "asked", run: run5.id, turn: turn.id, question: text, at: turn.startedAt });
2898
- const adapter = ADAPTERS[cli];
3256
+ const adapter = ADAPTERS[cli2];
2899
3257
  const args = adapter.args({
2900
3258
  prompt: followUpPrompt(text),
2901
3259
  sessionId: run5.sessionId,
@@ -2916,10 +3274,10 @@ var AgentRunner = class {
2916
3274
  * session under the directory it was started in, and looks for it there.
2917
3275
  */
2918
3276
  async spawnInto(run5, turn, args, timeoutMs, timeoutReason) {
2919
- const cli = run5.cli;
2920
- const adapter = ADAPTERS[cli];
3277
+ const cli2 = run5.cli;
3278
+ const adapter = ADAPTERS[cli2];
2921
3279
  await mkdir(this.config.dataDir, { recursive: true });
2922
- const refusal2 = cli === "codex" ? await this.codexGate.refusal() : null;
3280
+ const refusal2 = cli2 === "codex" ? await this.codexGate.refusal() : null;
2923
3281
  if (refusal2) {
2924
3282
  await this.finish(run5, turn, { ok: false, error: refusal2 });
2925
3283
  return;
@@ -2947,23 +3305,31 @@ var AgentRunner = class {
2947
3305
  stderr.push(chunk.toString("utf8"));
2948
3306
  if (stderr.length > 50) stderr.shift();
2949
3307
  });
3308
+ const trail = turn ? null : new StepTrail(run5);
3309
+ let reader = null;
2950
3310
  if (child.stdout) {
2951
3311
  const lines = createInterface({ input: child.stdout });
2952
3312
  lines.on("line", (line) => {
2953
3313
  const event = adapter.parse(line);
2954
3314
  if (!event) return;
2955
3315
  if (event.sessionId && !turn) run5.sessionId = event.sessionId;
3316
+ if (trail && cli2 === "codex" && run5.sessionId && !reader) reader = this.readSessions(run5, trail);
3317
+ const by = event.by ?? null;
2956
3318
  if (event.text) {
2957
- if (turn) {
3319
+ if (by !== null) {
3320
+ trail?.said(by, event.text);
3321
+ } else if (turn) {
2958
3322
  turn.reply = turn.reply ? `${turn.reply}
2959
3323
 
2960
3324
  ${event.text}` : event.text;
2961
3325
  } else {
2962
3326
  run5.messages.push(event.text);
2963
3327
  run5.report = event.text;
3328
+ trail?.said(null, event.text);
2964
3329
  }
2965
- this.hooks.onChange();
2966
3330
  }
3331
+ for (const parsed of event.steps ?? []) trail?.call(by, parsed);
3332
+ if (event.text || trail && event.steps) this.hooks.onChange();
2967
3333
  if (event.done) done = event.done;
2968
3334
  });
2969
3335
  }
@@ -2972,14 +3338,53 @@ ${event.text}` : event.text;
2972
3338
  });
2973
3339
  child.on("close", (code) => {
2974
3340
  clearTimeout(timer);
2975
- this.running = null;
2976
3341
  const outcome = running.abortReason ? { ok: false, error: running.abortReason } : done ?? {
2977
3342
  ok: false,
2978
- error: `${cli} exited with code ${code}${stderr.length ? `: ${stderr.join("").trim().split("\n").slice(-3).join(" ")}` : ""}`
3343
+ error: `${cli2} exited with code ${code}${stderr.length ? `: ${stderr.join("").trim().split("\n").slice(-3).join(" ")}` : ""}`
2979
3344
  };
2980
- void this.finish(run5, turn, outcome, running.abortReason !== null);
3345
+ void (async () => {
3346
+ if (reader) await this.stopReading(reader, trail);
3347
+ this.running = null;
3348
+ trail?.settle(outcome.ok);
3349
+ await this.finish(run5, turn, outcome, running.abortReason !== null);
3350
+ })();
2981
3351
  });
2982
3352
  }
3353
+ /**
3354
+ * Start reading a Codex run's session files for its subagents, now that the
3355
+ * stream has named the run's thread.
3356
+ */
3357
+ readSessions(run5, trail) {
3358
+ const reader = {
3359
+ sessions: new CodexSessions(run5.sessionId, new Date(run5.startedAt), this.codexHome),
3360
+ timer: setInterval(() => void this.readOnce(reader, trail), SESSIONS_EVERY_MS),
3361
+ reading: null
3362
+ };
3363
+ reader.timer.unref();
3364
+ return reader;
3365
+ }
3366
+ readOnce(reader, trail) {
3367
+ reader.reading ??= (async () => {
3368
+ try {
3369
+ const news = await reader.sessions.read();
3370
+ for (const entry of news) {
3371
+ if ("message" in entry) trail.said(entry.by, entry.message);
3372
+ else trail.call(entry.by, entry.step);
3373
+ }
3374
+ if (news.length) this.hooks.onChange();
3375
+ } catch (err) {
3376
+ console.warn(`[daily-focus] could not read Codex's session files: ${err.message}`);
3377
+ } finally {
3378
+ reader.reading = null;
3379
+ }
3380
+ })();
3381
+ return reader.reading;
3382
+ }
3383
+ async stopReading(reader, trail) {
3384
+ clearInterval(reader.timer);
3385
+ await reader.reading;
3386
+ await this.readOnce(reader, trail);
3387
+ }
2983
3388
  /**
2984
3389
  * Kill whatever is going, run or follow-up. What a run already renamed into
2985
3390
  * place stays: the brief it replaced is gone either way, and the archive has it.
@@ -3021,17 +3426,17 @@ ${event.text}` : event.text;
3021
3426
  const report = outcome.reply ?? run5.report;
3022
3427
  run5.report = report;
3023
3428
  run5.endedAt = at;
3024
- const messages = run5.messages;
3429
+ const { messages, steps } = run5;
3025
3430
  if (outcome.ok) {
3026
- await this.append({ event: "finished", run: run5.id, sessionId: run5.sessionId, report, messages, at });
3431
+ await this.append({ event: "finished", run: run5.id, sessionId: run5.sessionId, report, messages, steps, at });
3027
3432
  run5.status = "done";
3028
3433
  } else if (aborted) {
3029
3434
  run5.error = outcome.error ?? "stopped";
3030
- await this.append({ event: "aborted", run: run5.id, sessionId: run5.sessionId, reason: run5.error, report, messages, at });
3435
+ await this.append({ event: "aborted", run: run5.id, sessionId: run5.sessionId, reason: run5.error, report, messages, steps, at });
3031
3436
  run5.status = "aborted";
3032
3437
  } else {
3033
3438
  run5.error = outcome.error ?? "failed";
3034
- await this.append({ event: "failed", run: run5.id, sessionId: run5.sessionId, error: run5.error, report, messages, at });
3439
+ await this.append({ event: "failed", run: run5.id, sessionId: run5.sessionId, error: run5.error, report, messages, steps, at });
3035
3440
  run5.status = "failed";
3036
3441
  }
3037
3442
  this.hooks.onChange();
@@ -3078,23 +3483,23 @@ function watchDataDir(dir, onChange, { debounceMs = 120, ignore = [] } = {}) {
3078
3483
  }
3079
3484
 
3080
3485
  // src/assets.ts
3081
- import { readdir, stat as stat2 } from "node:fs/promises";
3082
- import { join as join2 } from "node:path";
3486
+ import { readdir as readdir3, stat as stat3 } from "node:fs/promises";
3487
+ import { join as join5 } from "node:path";
3083
3488
  async function computeAssetVersion(dir) {
3084
3489
  const parts = [];
3085
3490
  async function walk(current) {
3086
3491
  let entries;
3087
3492
  try {
3088
- entries = await readdir(current, { withFileTypes: true });
3493
+ entries = await readdir3(current, { withFileTypes: true });
3089
3494
  } catch {
3090
3495
  return;
3091
3496
  }
3092
3497
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
3093
- const path = join2(current, entry.name);
3498
+ const path = join5(current, entry.name);
3094
3499
  if (entry.isDirectory()) {
3095
3500
  await walk(path);
3096
3501
  } else {
3097
- const info = await stat2(path);
3502
+ const info = await stat3(path);
3098
3503
  parts.push(`${entry.name}:${info.size}:${info.mtimeMs}`);
3099
3504
  }
3100
3505
  }
@@ -3113,8 +3518,8 @@ var FAVICONS = {
3113
3518
  work: `<path d="M34 30V20a8 8 0 0 1 8-8h16a8 8 0 0 1 8 8v10" fill="none" stroke="#2a78d6" stroke-width="9"/><path fill="#2a78d6" fill-rule="evenodd" d="M16 30h68a10 10 0 0 1 10 10v38a10 10 0 0 1-10 10H16A10 10 0 0 1 6 78V40a10 10 0 0 1 10-10ZM40 46v14h20V46Z"/>`,
3114
3519
  personal: `<path fill="#1baf7a" fill-rule="evenodd" d="M50 8 96 48H84v44H16V48H4ZM41 92V66h18v26Z"/>`
3115
3520
  };
3116
- function faviconSvg(profile) {
3117
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${FAVICONS[profile]}</svg>`;
3521
+ function faviconSvg(profile2) {
3522
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${FAVICONS[profile2]}</svg>`;
3118
3523
  }
3119
3524
 
3120
3525
  // src/guard.ts
@@ -3146,526 +3551,1263 @@ function refusal(req, listenHost) {
3146
3551
  return null;
3147
3552
  }
3148
3553
 
3149
- // src/settings.ts
3150
- var SETTING_GROUPS = [
3151
- { id: "instance", title: "This dashboard" },
3152
- { id: "agent", title: "Morning agent" },
3153
- { id: "assistant", title: "Assistant" },
3154
- { id: "github", title: "Pull requests" },
3155
- { id: "jira", title: "Jira tickets" },
3156
- { id: "calendar", title: "Calendar" },
3157
- { id: "day", title: "Working day" }
3158
- ];
3159
- var OFF_OR_CLI = [
3160
- { value: "off", label: "Off" },
3161
- { value: "claude", label: "Claude Code" },
3162
- { value: "codex", label: "Codex" }
3163
- ];
3164
- var EFFORTS = ["low", "medium", "high", "xhigh", "max"].map((value) => ({ value, label: value }));
3165
- var always = (text) => () => text;
3166
- var SETTINGS = [
3167
- {
3168
- key: "DAILY_FOCUS_PROFILE",
3169
- group: "instance",
3170
- label: "What this dashboard briefs",
3171
- help: "Work or home. Picks the morning prompt and the defaults below: the personal profile switches off Jira and free time.",
3172
- kind: "choice",
3173
- choices: PROFILES.map((value) => ({ value, label: value === "work" ? "Work" : "Personal life" })),
3174
- fallback: always("Work")
3175
- },
3176
- {
3177
- key: "DAILY_FOCUS_AGENT",
3178
- group: "agent",
3179
- label: "Write the brief with",
3180
- help: "The coding-agent CLI the dashboard runs each morning to write your brief. It has to be installed and logged in.",
3181
- kind: "choice",
3182
- choices: OFF_OR_CLI,
3183
- fallback: always("Off")
3184
- },
3185
- {
3186
- key: "DAILY_FOCUS_AGENT_AT",
3187
- group: "agent",
3188
- label: "Run it at",
3189
- help: "Local time, such as 06:30, or off to run it only when you press refresh. If the dashboard was off at that time, it runs when it next starts that day.",
3190
- kind: "time",
3191
- fallback: always("07:00")
3192
- },
3193
- {
3194
- key: "DAILY_FOCUS_AGENT_DAYS",
3195
- group: "agent",
3196
- label: "On these days",
3197
- help: "Cron-style weekday numbers: 1-5 is Monday to Friday, 0-4 is Sunday to Thursday, 0,6 is the weekend.",
3198
- kind: "text",
3199
- fallback: always("1-5, Monday to Friday")
3200
- },
3201
- {
3202
- key: "DAILY_FOCUS_AGENT_MODEL",
3203
- group: "agent",
3204
- label: "Model",
3205
- help: "Passed to the CLI as it is, such as opus for Claude Code. Empty uses the CLI's own default.",
3206
- kind: "text",
3207
- fallback: always("The CLI's own")
3208
- },
3209
- {
3210
- key: "DAILY_FOCUS_AGENT_EFFORT",
3211
- group: "agent",
3212
- label: "Effort",
3213
- help: "A brief is worth a high effort.",
3214
- kind: "choice",
3215
- choices: EFFORTS,
3216
- fallback: always("The CLI's own")
3217
- },
3218
- {
3219
- key: "DAILY_FOCUS_AGENT_TOOLS",
3220
- group: "agent",
3221
- label: "Tools Claude Code may use",
3222
- help: "In Claude Code's permission syntax, comma-separated. Add your calendar, chat and document connectors by name, or the brief reports them as unreachable. The default allows every Gmail tool, sending included; name the tools you want instead to narrow it. Ignored by Codex.",
3223
- kind: "list",
3224
- fallback: always("Bash(gh *), Bash(acli *), WebFetch, mcp__claude_ai_Gmail"),
3225
- advanced: true
3226
- },
3227
- {
3228
- key: "DAILY_FOCUS_AGENT_BIN",
3229
- group: "agent",
3230
- label: "Path to the CLI",
3231
- help: "Only needed when the dashboard can't find the CLI on its own.",
3232
- kind: "text",
3233
- fallback: always("Found on PATH"),
3234
- advanced: true
3235
- },
3236
- {
3237
- key: "DAILY_FOCUS_STALE_AFTER_HOURS",
3238
- group: "agent",
3239
- label: "Expect a new brief every",
3240
- help: "Hours, counted only on the days above. After that, and 45 minutes more, the page says the agent may not have run.",
3241
- kind: "number",
3242
- fallback: always("24 hours"),
3243
- advanced: true
3244
- },
3245
- {
3246
- key: "DAILY_FOCUS_ASSISTANT",
3247
- group: "assistant",
3248
- label: "Answer questions with",
3249
- help: "Puts an assistant in every item's panel, told to read and draft and never to send, post or edit. Under Codex, every connector write but a Gmail draft is refused.",
3250
- kind: "choice",
3251
- choices: OFF_OR_CLI,
3252
- fallback: always("Off")
3253
- },
3254
- {
3255
- key: "DAILY_FOCUS_ASSISTANT_MODEL",
3256
- group: "assistant",
3257
- label: "Model",
3258
- help: "Passed to the CLI as it is. Empty uses the CLI's own default.",
3259
- kind: "text",
3260
- fallback: always("The CLI's own")
3261
- },
3262
- {
3263
- key: "DAILY_FOCUS_ASSISTANT_EFFORT",
3264
- group: "assistant",
3265
- label: "Effort",
3266
- help: "Passed to the CLI as it is.",
3267
- kind: "choice",
3268
- choices: EFFORTS,
3269
- fallback: always("The CLI's own")
3270
- },
3271
- {
3272
- key: "DAILY_FOCUS_ASSISTANT_TOOLS",
3273
- group: "assistant",
3274
- label: "Tools Claude Code may use",
3275
- help: "In Claude Code's permission syntax, comma-separated. Editing tools are denied whatever this says. The default allows every Gmail tool, sending included; name the tools you want instead to narrow it. Ignored by Codex.",
3276
- kind: "list",
3277
- fallback: always("Bash(gh *), Bash(acli *), WebFetch, mcp__claude_ai_Gmail"),
3278
- advanced: true
3279
- },
3280
- {
3281
- key: "DAILY_FOCUS_ASSISTANT_COMPUTER_USE",
3282
- group: "assistant",
3283
- label: "Computer use",
3284
- help: "Codex only. On lets the assistant use Codex's computer-use and browser plugins, which act in your apps and browser with your sign-ins. Off switches them off for its runs. Claude Code uses only the tools in its list.",
3285
- kind: "flag",
3286
- fallback: always("Off")
3287
- },
3288
- {
3289
- key: "DAILY_FOCUS_ASSISTANT_BIN",
3290
- group: "assistant",
3291
- label: "Path to the CLI",
3292
- help: "Only needed when the dashboard can't find the CLI on its own.",
3293
- kind: "text",
3294
- fallback: always("Found on PATH"),
3295
- advanced: true
3296
- },
3297
- {
3298
- key: "DAILY_FOCUS_GITHUB",
3299
- group: "github",
3300
- label: "Pull request board",
3301
- help: "Reads your open pull requests through the GitHub CLI. Sign in with gh auth login. The Jira ticket board uses it too, to catch merges Jira hasn't heard about yet.",
3302
- kind: "flag",
3303
- fallback: always("On")
3304
- },
3305
- {
3306
- key: "DAILY_FOCUS_GITHUB_ACCOUNTS",
3307
- group: "github",
3308
- label: "Accounts",
3309
- help: "GitHub logins to read as, comma-separated, when gh holds more than one. Empty uses the active one.",
3310
- kind: "list",
3311
- fallback: always("gh's active account")
3312
- },
3313
- {
3314
- key: "DAILY_FOCUS_GITHUB_SCOPE",
3315
- group: "github",
3316
- label: "Only these organisations or repositories",
3317
- help: "Comma-separated: acme, or acme/webapp for one repository. Empty counts every pull request you have open.",
3318
- kind: "list",
3319
- fallback: always("Everything")
3320
- },
3321
- {
3322
- key: "DAILY_FOCUS_GITHUB_MERGE_GATE_CHECKS",
3323
- group: "github",
3324
- label: "Merge gate checks",
3325
- help: "Exact names of checks that stand for a repository's whole merge policy, comma-separated. Pending ones get their own bucket.",
3326
- kind: "list",
3327
- fallback: always("None"),
3328
- advanced: true
3329
- },
3330
- {
3331
- key: "DAILY_FOCUS_GITHUB_POLL_MINUTES",
3332
- group: "github",
3333
- label: "Read every",
3334
- help: "Minutes between reads while a tab is open.",
3335
- kind: "number",
3336
- fallback: always("5 minutes"),
3337
- advanced: true
3338
- },
3339
- {
3340
- key: "DAILY_FOCUS_GH",
3341
- group: "github",
3342
- label: "Path to gh",
3343
- help: "Only needed when the dashboard can't find the GitHub CLI on its own.",
3344
- kind: "text",
3345
- fallback: always("Found on PATH"),
3346
- advanced: true
3554
+ // src/onboarding.ts
3555
+ import { spawn as spawn2 } from "node:child_process";
3556
+ import { mkdir as mkdir2, mkdtemp, readFile as readFile8, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
3557
+ import { tmpdir as tmpdir2 } from "node:os";
3558
+ import { join as join6 } from "node:path";
3559
+ import { createInterface as createInterface2 } from "node:readline";
3560
+
3561
+ // src/questions.ts
3562
+ var INSTALL = {
3563
+ codex: { label: "Install Codex", url: "https://developers.openai.com/codex/cli" },
3564
+ claude: { label: "Install Claude Code", url: "https://code.claude.com/docs" }
3565
+ };
3566
+ function option(value, label, detail = null, group = null) {
3567
+ return { value, label, detail, group };
3568
+ }
3569
+ function choice(id, options, selected, extra = {}) {
3570
+ return { id, label: null, multiple: false, options, selected, custom: null, ...extra };
3571
+ }
3572
+ function field(id, label, value, extra = {}) {
3573
+ return { id, label, value, placeholder: null, help: null, ...extra };
3574
+ }
3575
+ function card(parts) {
3576
+ return { why: null, choices: [], fields: [], problem: null, ...parts };
3577
+ }
3578
+ function one(answer, id) {
3579
+ return answer.choices[id]?.[0] ?? null;
3580
+ }
3581
+ function str3(value) {
3582
+ return typeof value === "string" ? value.trim() : "";
3583
+ }
3584
+ function proposalOf(ctx, id) {
3585
+ const proposal = ctx.proposals[id];
3586
+ return typeof proposal === "object" && proposal !== null && !Array.isArray(proposal) ? proposal : null;
3587
+ }
3588
+ function listed(names) {
3589
+ if (names.length <= 1) return names.join("");
3590
+ return `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
3591
+ }
3592
+ function ticked(name) {
3593
+ return `\`${name.replaceAll("`", "'")}\``;
3594
+ }
3595
+ function binFor(ctx, cli2) {
3596
+ const found = cli2 ? ctx.clis?.[cli2] : null;
3597
+ return found?.path && !found.onPath ? found.path : null;
3598
+ }
3599
+ var cli = {
3600
+ id: "cli",
3601
+ title: () => "Which agent should write your brief?",
3602
+ topic: "your coding agent",
3603
+ later: { label: "Decide later", summary: "No agent yet: the boards work, and there is no brief until you choose one." },
3604
+ card(ctx) {
3605
+ if (!ctx.clis) return card({});
3606
+ const report = ctx.clis;
3607
+ const ready = CLI_PREFERENCE.filter((name) => report[name].ready);
3608
+ if (ready.length === 0) {
3609
+ const lines = CLI_PREFERENCE.map((name) => `${report[name].problem ?? `${CLI_LABEL[name]} can't be used`}.`);
3610
+ const fix = CLI_PREFERENCE.map((name) => report[name].fix).find((command3) => command3) ?? null;
3611
+ return card({
3612
+ problem: {
3613
+ message: `${lines.join(" ")} ${fix ? "Run this in a terminal, then check again." : "Install one, log in once in a terminal, then check again."}`,
3614
+ command: fix,
3615
+ blocking: true,
3616
+ links: CLI_PREFERENCE.filter((name) => !report[name].path).map((name) => INSTALL[name])
3617
+ },
3618
+ fields: [
3619
+ field("path", "Installed somewhere setup didn\u2019t look? Its path", "", {
3620
+ placeholder: "/opt/homebrew/bin/codex",
3621
+ help: "The program itself, codex or claude. Setup checks it before using it."
3622
+ })
3623
+ ]
3624
+ });
3625
+ }
3626
+ const set = ctx.current.DAILY_FOCUS_AGENT;
3627
+ const others = CLI_PREFERENCE.filter((name) => !report[name].ready && report[name].path).map((name) => `${report[name].problem}.`);
3628
+ return card({
3629
+ why: [
3630
+ ready.length > 1 ? `Both are ready on this ${ctx.machine}, so it uses Codex unless you say otherwise.` : `${CLI_LABEL[ready[0]]} is the one ready on this ${ctx.machine}.`,
3631
+ ...others
3632
+ ].join(" "),
3633
+ choices: [
3634
+ choice(
3635
+ "cli",
3636
+ ready.map((name) => option(name, CLI_LABEL[name], [report[name].version, report[name].path].filter(Boolean).join(", at "))),
3637
+ [set && ready.includes(set) ? set : ready[0]]
3638
+ )
3639
+ ]
3640
+ });
3347
3641
  },
3348
- {
3349
- key: "DAILY_FOCUS_JIRA",
3350
- group: "jira",
3351
- label: "Jira ticket board",
3352
- help: "Flags tickets whose status disagrees with their pull requests, through the Atlassian CLI. Sign in with acli jira auth login.",
3353
- kind: "flag",
3354
- fallback: (profile) => profile === "personal" ? "Off" : "On"
3642
+ summary: (answer) => {
3643
+ const chosen = CLI_LABEL[one(answer, "cli")];
3644
+ return chosen ? `${chosen} writes your brief.` : "No agent writes your brief yet.";
3355
3645
  },
3356
- {
3357
- key: "DAILY_FOCUS_JIRA_PROJECTS",
3358
- group: "jira",
3359
- label: "Only these projects",
3360
- help: "Project keys, comma-separated. Empty means every project you can see.",
3361
- kind: "list",
3362
- fallback: always("Every project")
3646
+ // The assistant comes with it, on the same CLI, unless it has been set: it
3647
+ // only reads and drafts, and it is what the Ask button on every row needs.
3648
+ settings: (answer, ctx) => {
3649
+ const chosen = one(answer, "cli");
3650
+ const bin = binFor(ctx, chosen);
3651
+ const assistant = chosen && !ctx.current.DAILY_FOCUS_ASSISTANT;
3652
+ return {
3653
+ DAILY_FOCUS_AGENT: chosen,
3654
+ ...bin ? { DAILY_FOCUS_AGENT_BIN: bin } : {},
3655
+ ...assistant ? { DAILY_FOCUS_ASSISTANT: chosen, ...bin ? { DAILY_FOCUS_ASSISTANT_BIN: bin } : {} } : {}
3656
+ };
3657
+ }
3658
+ };
3659
+ var profile = {
3660
+ id: "profile",
3661
+ topic: "what it covers",
3662
+ title: () => "What should your brief cover?",
3663
+ later: null,
3664
+ card: (ctx) => card({
3665
+ choices: [
3666
+ choice(
3667
+ "profile",
3668
+ [
3669
+ option("work", "Your work", "Work mail and calendars, pull requests, tickets and meeting notes."),
3670
+ option("personal", "Your personal life", "Personal mail, family calendars, reminders and messages.")
3671
+ ],
3672
+ [ctx.current.DAILY_FOCUS_PROFILE === "personal" ? "personal" : "work"]
3673
+ )
3674
+ ],
3675
+ why: "One dashboard briefs one of them. For both, run a second dashboard with its own store."
3676
+ }),
3677
+ summary: (answer) => one(answer, "profile") === "personal" ? "It briefs your personal life." : "It briefs your work.",
3678
+ settings: (answer) => ({ DAILY_FOCUS_PROFILE: one(answer, "profile") })
3679
+ };
3680
+ var TIMES = ["06:00", "06:30", "07:00", "07:30", "08:00"];
3681
+ var DAYS = { "1-5": "Weekdays", "0-6": "Every day" };
3682
+ var schedule = {
3683
+ id: "schedule",
3684
+ topic: "when it runs",
3685
+ title: () => "When should your brief be ready?",
3686
+ applies: (ctx) => ctx.cli !== null,
3687
+ later: null,
3688
+ card(ctx) {
3689
+ const at = ctx.current.DAILY_FOCUS_AGENT_AT || "07:00";
3690
+ const days = ctx.current.DAILY_FOCUS_AGENT_DAYS || "1-5";
3691
+ const times = [.../* @__PURE__ */ new Set([...TIMES, ...at === "off" ? [] : [at]])].sort();
3692
+ return card({
3693
+ why: `It starts then and takes a few minutes. If the ${ctx.machine} is asleep or the dashboard isn't running then, it runs as soon as it is, that day.`,
3694
+ choices: [
3695
+ choice("at", [...times.map((time) => option(time, time)), option("off", "Only when I ask")], [at], {
3696
+ label: "Time",
3697
+ custom: "Another time, such as 05:45"
3698
+ }),
3699
+ choice("days", Object.entries({ ...DAYS, ...DAYS[days] ? {} : { [days]: days } }).map(([value, label]) => option(value, label)), [days], {
3700
+ label: "Days"
3701
+ })
3702
+ ]
3703
+ });
3363
3704
  },
3364
- {
3365
- key: "DAILY_FOCUS_JIRA_HOLD_STATUSES",
3366
- group: "jira",
3367
- label: "Statuses that mean on hold",
3368
- help: "Spelled as your Jira spells them, comma-separated, such as Blocked, On Hold. Tickets in them are not flagged for having no code linked.",
3369
- kind: "list",
3370
- fallback: always("None")
3705
+ summary(answer) {
3706
+ const at = one(answer, "at");
3707
+ if (!at || at === "off") return "It runs only when you ask.";
3708
+ const days = one(answer, "days") ?? "1-5";
3709
+ return `It runs at ${at} ${days === "1-5" ? "on weekdays" : days === "0-6" ? "every day" : `on days ${days}`}.`;
3371
3710
  },
3372
- {
3373
- key: "DAILY_FOCUS_JIRA_IN_PROGRESS_STATUSES",
3374
- group: "jira",
3375
- label: "Statuses that mean working on it",
3376
- help: "What the Working on view shows, comma-separated. Use In Progress to leave out In Review.",
3377
- kind: "list",
3378
- fallback: always("Everything Jira calls in progress")
3711
+ settings: (answer) => ({ DAILY_FOCUS_AGENT_AT: one(answer, "at"), DAILY_FOCUS_AGENT_DAYS: one(answer, "days") })
3712
+ };
3713
+ function proposedAccounts(proposal) {
3714
+ const raw = Array.isArray(proposal?.githubAccounts) ? proposal.githubAccounts : [];
3715
+ return raw.flatMap((entry) => {
3716
+ const account = typeof entry === "object" && entry !== null ? entry : {};
3717
+ const login = str3(account.login);
3718
+ return login ? [{ login, note: str3(account.note) }] : [];
3719
+ });
3720
+ }
3721
+ function githubAccounts(ctx) {
3722
+ const accounts = /* @__PURE__ */ new Map();
3723
+ for (const login of ctx.identity.logins) accounts.set(login.toLowerCase(), { login, note: "" });
3724
+ for (const { login, note } of proposedAccounts(proposalOf(ctx, "identity"))) {
3725
+ accounts.set(login.toLowerCase(), { login: accounts.get(login.toLowerCase())?.login ?? login, note });
3726
+ }
3727
+ return [...accounts.values()];
3728
+ }
3729
+ var identity2 = {
3730
+ id: "identity",
3731
+ topic: "who you are",
3732
+ title: () => "Is this you?",
3733
+ // Clearing three fields and unticking the accounts is a chore; one button, named for what it does, isn't.
3734
+ later: { label: "Leave this out", summary: "Who you are is left out, so the brief can\u2019t tell your to-dos from other people\u2019s." },
3735
+ card(ctx) {
3736
+ const proposal = proposalOf(ctx, "identity");
3737
+ const names = Array.isArray(proposal?.names) ? proposal.names.map(str3).filter(Boolean) : [];
3738
+ const name = names.length ? names.join(", ") : ctx.identity.name ?? "";
3739
+ const email = str3(proposal?.email) || (ctx.identity.email ?? "");
3740
+ const accounts = githubAccounts(ctx);
3741
+ return card({
3742
+ why: str3(proposal?.why) || "The brief tells your to-dos from everyone else\u2019s by these.",
3743
+ fields: [
3744
+ field("name", "Your name, as meeting notes write it", name, {
3745
+ placeholder: "Robin Lark",
3746
+ help: "Add short forms after a comma, such as Robin L."
3747
+ }),
3748
+ field("email", ctx.profile === "personal" ? "Your email" : "Your work email", email, { placeholder: "robin@example.com" })
3749
+ ],
3750
+ choices: [
3751
+ choice(
3752
+ "github",
3753
+ accounts.map((account) => option(account.login, account.login, account.note || null)),
3754
+ accounts.map((account) => account.login),
3755
+ { label: "Your GitHub accounts", multiple: true, custom: "Add a GitHub login" }
3756
+ )
3757
+ ]
3758
+ });
3379
3759
  },
3380
- {
3381
- key: "DAILY_FOCUS_JIRA_SITE",
3382
- group: "jira",
3383
- label: "Atlassian site",
3384
- help: "The host links are built from, such as acme.atlassian.net. Empty uses the one acli signed in to.",
3385
- kind: "text",
3386
- fallback: always("acli's own"),
3387
- advanced: true
3760
+ summary(answer) {
3761
+ const name = answer.fields.name?.split(",")[0]?.trim();
3762
+ const logins = answer.choices.github ?? [];
3763
+ const rest = [answer.fields.email?.trim() ?? "", logins.length ? `${listed(logins)} on GitHub` : ""].filter(Boolean);
3764
+ if (!name) return rest.length ? `You're ${listed(rest)}.` : "No details about you.";
3765
+ return `You're ${name}${rest.length ? `, ${rest.join(", ")}` : ""}.`;
3388
3766
  },
3389
- {
3390
- key: "DAILY_FOCUS_JIRA_POLL_MINUTES",
3391
- group: "jira",
3392
- label: "Read every",
3393
- help: "Minutes between reads while a tab is open.",
3394
- kind: "number",
3395
- fallback: always("15 minutes"),
3396
- advanced: true
3767
+ settings(answer, ctx) {
3768
+ const values = {};
3769
+ if (answer.fields.email?.trim()) values.DAILY_FOCUS_CALENDAR_ADDRESSES = answer.fields.email.trim();
3770
+ const logins = answer.choices.github ?? [];
3771
+ if (logins.length > 1 || logins.length === 1 && logins[0] !== ctx.identity.logins[0]) {
3772
+ values.DAILY_FOCUS_GITHUB_ACCOUNTS = logins.join(", ");
3773
+ }
3774
+ return values;
3397
3775
  },
3398
- {
3399
- key: "DAILY_FOCUS_ACLI",
3400
- group: "jira",
3401
- label: "Path to acli",
3402
- help: "Only needed when the dashboard can't find the Atlassian CLI on its own.",
3403
- kind: "text",
3404
- fallback: always("Found on PATH"),
3405
- advanced: true
3776
+ sources(answer, ctx) {
3777
+ const names = (answer.fields.name ?? "").split(",").map((n) => n.trim()).filter(Boolean);
3778
+ const email = answer.fields.email?.trim();
3779
+ const notes = new Map(githubAccounts(ctx).map((account) => [account.login, account.note]));
3780
+ const logins = answer.choices.github ?? [];
3781
+ const lines = [
3782
+ names.length ? `- Name, as meeting notes and Doc assignments write it: ${ticked(names[0])}${names.length > 1 ? `, also ${listed(names.slice(1).map(ticked))}` : ""}` : null,
3783
+ email ? `- ${ctx.profile === "personal" ? "Personal" : "Work"} email: ${ticked(email)}` : null,
3784
+ logins.length ? [
3785
+ `- GitHub ${logins.length > 1 ? "logins" : "login"}, which review requests and authorship are judged against:`,
3786
+ ...logins.map((login) => ` - ${ticked(login)}${notes.get(login) ? ` \u2014 ${notes.get(login)}` : ""}`)
3787
+ ].join("\n") : null
3788
+ ].filter((line) => line !== null);
3789
+ return lines.length ? [{ heading: "Identity", body: lines.join("\n") }] : [];
3406
3790
  },
3407
- {
3408
- key: "DAILY_FOCUS_CALENDAR",
3409
- group: "calendar",
3410
- label: "Live agenda",
3411
- help: "Reads today's events from Calendar.app on a Mac, so a meeting declined at eleven frees the slot. Off uses the events in the brief.",
3412
- kind: "flag",
3413
- fallback: always("On")
3791
+ agent: {
3792
+ brief: (ctx) => "Who the user is, as their morning brief needs to know them: their name as meeting notes and document assignments write it, with any short forms colleagues use, their " + (ctx.profile === "personal" ? "personal" : "work") + " email address, and every GitHub account they work as: gh may hold several, such as one for an employer\u2019s organisation. Give each account a few words on what it is for when you can tell. Start from what the dashboard found, below. Check the address they send mail from, how others address them, and `gh auth status`.",
3793
+ facts: (ctx) => [
3794
+ ctx.identity.name ? `- gh says their name is ${ctx.identity.name}` : null,
3795
+ ctx.identity.logins.length ? `- gh is logged in to GitHub as ${listed(ctx.identity.logins)}; the first is active` : null,
3796
+ ctx.identity.email ? `- git's global email is ${ctx.identity.email}` : null
3797
+ ].filter(Boolean).join("\n") || null,
3798
+ schema: {
3799
+ type: "object",
3800
+ properties: {
3801
+ why: { type: "string", description: "One or two sentences on where these came from." },
3802
+ names: { type: "array", items: { type: "string" }, description: "Full name first, then short forms." },
3803
+ email: { type: "string" },
3804
+ githubAccounts: {
3805
+ type: "array",
3806
+ items: {
3807
+ type: "object",
3808
+ properties: {
3809
+ login: { type: "string" },
3810
+ note: { type: "string", description: "What the account is for, in a few words, or empty." }
3811
+ },
3812
+ required: ["login", "note"],
3813
+ additionalProperties: false
3814
+ }
3815
+ }
3816
+ },
3817
+ required: ["why", "names", "email", "githubAccounts"],
3818
+ additionalProperties: false
3819
+ }
3820
+ }
3821
+ };
3822
+ function healthText(board, via) {
3823
+ if (board.reason) return board.reason;
3824
+ if (board.fetchedAt) return `Connected through ${via}.`;
3825
+ if (board.enabled) return `Checking ${via}\u2026`;
3826
+ return null;
3827
+ }
3828
+ var boards = {
3829
+ id: "boards",
3830
+ topic: "the boards",
3831
+ title: () => "What else should the dashboard show?",
3832
+ later: null,
3833
+ card(ctx) {
3834
+ const { github, jira } = ctx.boards;
3835
+ const selected = [
3836
+ github.enabled && !github.reason ? "github" : null,
3837
+ jira.enabled && !jira.reason && ctx.profile === "work" ? "jira" : null
3838
+ ].filter((value) => value !== null);
3839
+ return card({
3840
+ why: "Both are read live, beside the brief, so a merge or a status change shows up within minutes.",
3841
+ choices: [
3842
+ choice(
3843
+ "boards",
3844
+ [
3845
+ option("github", "Your pull requests", healthText(github, "gh") ?? "Through the GitHub CLI, gh."),
3846
+ option("jira", "Your Jira tickets", healthText(jira, "acli") ?? "Through the Atlassian CLI, acli.")
3847
+ ],
3848
+ selected,
3849
+ { multiple: true }
3850
+ )
3851
+ ]
3852
+ });
3414
3853
  },
3415
- {
3416
- key: "DAILY_FOCUS_CALENDARS",
3417
- group: "calendar",
3418
- label: "Calendars",
3419
- help: "Names as Calendar.app shows them, comma-separated. Empty reads none, and the agenda uses the brief.",
3420
- kind: "list",
3421
- fallback: always("None")
3854
+ summary(answer) {
3855
+ const on = answer.choices.boards ?? [];
3856
+ const names = [on.includes("github") ? "your pull requests" : null, on.includes("jira") ? "your Jira tickets" : null].filter(
3857
+ (name) => name !== null
3858
+ );
3859
+ return names.length ? `It shows ${listed(names)}.` : "No boards beside the brief.";
3422
3860
  },
3423
- {
3424
- key: "DAILY_FOCUS_CALENDAR_ADDRESSES",
3425
- group: "calendar",
3426
- label: "Your email addresses",
3427
- help: "Comma-separated. Used only to find your reply on an invitation, so a meeting you declined stops blocking your time.",
3428
- kind: "list",
3429
- fallback: always("None")
3861
+ settings(answer) {
3862
+ const on = answer.choices.boards ?? [];
3863
+ return { DAILY_FOCUS_GITHUB: on.includes("github") ? "on" : "off", DAILY_FOCUS_JIRA: on.includes("jira") ? "on" : "off" };
3864
+ }
3865
+ };
3866
+ var HOLIDAYS = "Holidays";
3867
+ function calendarEntries(ctx) {
3868
+ const proposal = proposalOf(ctx, "calendars");
3869
+ const verdicts = Array.isArray(proposal?.calendars) ? proposal.calendars.flatMap((raw) => {
3870
+ const entry = typeof raw === "object" && raw !== null ? raw : {};
3871
+ const title = str3(entry.title);
3872
+ return title ? [{ title, account: str3(entry.account), include: entry.include === true, holiday: entry.holiday === true, briefName: str3(entry.briefName), note: str3(entry.note) }] : [];
3873
+ }) : [];
3874
+ const configured = new Set((ctx.current.DAILY_FOCUS_CALENDARS ?? "").split(",").map((name) => name.trim().toLowerCase()).filter(Boolean));
3875
+ const used = /* @__PURE__ */ new Set();
3876
+ const entries = [];
3877
+ for (const calendar of Array.isArray(ctx.calendars) ? ctx.calendars : []) {
3878
+ const key = calendar.title.toLowerCase();
3879
+ const verdict = verdicts.find((v) => !used.has(v) && v.title.toLowerCase() === key && v.account.toLowerCase() === calendar.source.toLowerCase()) ?? verdicts.find((v) => !used.has(v) && v.title.toLowerCase() === key);
3880
+ if (verdict) used.add(verdict);
3881
+ entries.push({
3882
+ value: `live:${calendar.source}:${calendar.title}`,
3883
+ title: calendar.title,
3884
+ group: verdict?.holiday ? HOLIDAYS : calendar.source || "Calendar.app",
3885
+ live: true,
3886
+ holiday: verdict?.holiday ?? false,
3887
+ briefName: verdict?.briefName ?? "",
3888
+ note: verdict?.note ?? "",
3889
+ include: verdict ? verdict.include : configured.has(key)
3890
+ });
3891
+ }
3892
+ for (const verdict of verdicts) {
3893
+ if (used.has(verdict)) continue;
3894
+ entries.push({
3895
+ value: `brief:${verdict.account}:${verdict.title}`,
3896
+ title: verdict.title,
3897
+ group: verdict.holiday ? HOLIDAYS : verdict.account || "Your calendar account",
3898
+ live: false,
3899
+ holiday: verdict.holiday,
3900
+ briefName: verdict.briefName,
3901
+ note: verdict.note,
3902
+ include: verdict.include
3903
+ });
3904
+ }
3905
+ return entries.sort((a, b) => Number(a.group === HOLIDAYS) - Number(b.group === HOLIDAYS));
3906
+ }
3907
+ function chosenCalendars(answer, ctx) {
3908
+ const all = new Map(calendarEntries(ctx).map((entry) => [entry.value, entry]));
3909
+ const values = answer.choices.calendars ?? [];
3910
+ return {
3911
+ entries: values.flatMap((value) => all.get(value) ?? []),
3912
+ typed: values.filter((value) => !all.has(value)).map((value) => value.trim()).filter(Boolean)
3913
+ };
3914
+ }
3915
+ var calendars = {
3916
+ id: "calendars",
3917
+ topic: "your calendars",
3918
+ title: (ctx) => ctx.profile === "personal" ? "Which calendars belong in your brief?" : "Which calendars belong in your work brief?",
3919
+ later: null,
3920
+ card(ctx) {
3921
+ const entries = calendarEntries(ctx);
3922
+ const proposal = proposalOf(ctx, "calendars");
3923
+ const problem = ctx.calendar.supported && !ctx.calendar.built ? {
3924
+ message: "The live agenda reads Calendar.app through a small helper, built once with this command. Until then it uses the brief\u2019s events, so this list is from your calendar account.",
3925
+ command: ctx.calendar.buildCommand,
3926
+ blocking: false
3927
+ } : typeof ctx.calendars === "string" ? { message: `Calendar.app couldn't be read: ${ctx.calendars}`, command: null, blocking: false } : null;
3928
+ return card({
3929
+ why: str3(proposal?.why) || null,
3930
+ problem,
3931
+ choices: [
3932
+ choice(
3933
+ "calendars",
3934
+ entries.map((entry) => option(entry.value, entry.title, entry.note || null, entry.group)),
3935
+ entries.filter((entry) => entry.include).map((entry) => entry.value),
3936
+ { multiple: true, custom: "Add a calendar by name" }
3937
+ )
3938
+ ]
3939
+ });
3430
3940
  },
3431
- {
3432
- key: "DAILY_FOCUS_CALENDAR_POLL_MINUTES",
3433
- group: "calendar",
3434
- label: "Read every",
3435
- help: "Minutes between reads while a tab is open.",
3436
- kind: "number",
3437
- fallback: always("5 minutes"),
3438
- advanced: true
3941
+ summary(answer, ctx) {
3942
+ const { entries, typed } = chosenCalendars(answer, ctx);
3943
+ const names = [...entries.map((entry) => entry.title), ...typed];
3944
+ if (names.length === 0) return "No calendars.";
3945
+ if (names.length <= 3) return `It reads ${listed(names)}.`;
3946
+ return `It reads ${names.slice(0, 2).join(", ")} and ${names.length - 2} more calendars.`;
3439
3947
  },
3440
- {
3441
- key: "DAILY_FOCUS_CALENDAR_APP",
3442
- group: "calendar",
3443
- label: "Path to the calendar helper",
3444
- help: "Only needed when the helper is somewhere other than where it was built.",
3445
- kind: "text",
3446
- fallback: always("The built copy"),
3447
- advanced: true
3948
+ settings(answer, ctx) {
3949
+ const { entries, typed } = chosenCalendars(answer, ctx);
3950
+ const live = [.../* @__PURE__ */ new Set([...entries.filter((entry) => entry.live).map((entry) => entry.title), ...typed])];
3951
+ return { DAILY_FOCUS_CALENDARS: live.filter((name) => !name.includes(",")).join(", ") || null };
3448
3952
  },
3449
- {
3450
- key: "DAILY_FOCUS_FREE_WINDOWS",
3451
- group: "day",
3452
- label: "Show free time",
3453
- help: "Find focus windows between meetings in your working hours. Off shows events only.",
3454
- kind: "flag",
3455
- fallback: (profile) => profile === "personal" ? "Off" : "On"
3953
+ sources(answer, ctx) {
3954
+ const { entries, typed } = chosenCalendars(answer, ctx);
3955
+ const line = (entry) => `- ${ticked(entry.briefName || entry.title)}${entry.group !== HOLIDAYS && entry.group ? ` (${entry.group})` : ""}`;
3956
+ const own = [...entries.filter((entry) => !entry.holiday).map(line), ...typed.map((name) => `- ${ticked(name)}`)];
3957
+ const holidays = entries.filter((entry) => entry.holiday).map(line);
3958
+ return [
3959
+ ...own.length ? [{ heading: "Calendars", body: `Query these, and treat their events as blocking time:
3960
+
3961
+ ${own.join("\n")}` }] : [],
3962
+ ...holidays.length ? [{ heading: "Holiday calendars", body: `Call out a holiday today or within the next 14 days:
3963
+
3964
+ ${holidays.join("\n")}` }] : []
3965
+ ];
3456
3966
  },
3457
- {
3458
- key: "DAILY_FOCUS_WORK_START",
3459
- group: "day",
3460
- label: "Working day starts at",
3461
- help: "Hour of the day, 0 to 23, when the brief doesn't say.",
3462
- kind: "number",
3463
- fallback: always("9")
3967
+ agent: {
3968
+ brief: (ctx) => `Which calendars the user's ${ctx.profile} brief should read. ` + (Array.isArray(ctx.calendars) ? "Below is every calendar Calendar.app on this Mac has, with the account it is on and how many events it has today. " : "List the calendars their calendar connector reaches. ") + "Include " + (ctx.profile === "personal" ? "their own personal and family calendars, " : "their own work calendar, team calendars whose events block their time, ") + "and the holiday calendars for where they and the people they work with are. Leave out " + (ctx.profile === "personal" ? "work calendars, " : "personal and family calendars, ") + "birthdays, and shared calendars that are mostly other people's events. If you can reach their calendar through a connector, look at the coming week for evidence, and give `briefName`, the name the connector uses where it differs from Calendar.app\u2019s. " + (Array.isArray(ctx.calendars) ? "List every calendar from the list below, and any your connector has that Calendar.app does not. " : "") + '`note` is a few words shown under the name, such as "Your primary calendar" or "12 of your meetings this week", or empty.',
3969
+ facts(ctx) {
3970
+ if (typeof ctx.calendars === "string") return `Calendar.app could not be read (${ctx.calendars}), so work from your calendar connector alone.`;
3971
+ if (!Array.isArray(ctx.calendars)) return "There is no list from Calendar.app here, so work from your calendar connector alone.";
3972
+ if (ctx.calendars.length === 0) return "Calendar.app has no calendars.";
3973
+ return [
3974
+ "Calendar.app has these, as title (account): events today.",
3975
+ "",
3976
+ ...ctx.calendars.map((calendar) => `- ${calendar.title} (${calendar.source || "no account"}): ${calendar.eventsToday}`)
3977
+ ].join("\n");
3978
+ },
3979
+ schema: {
3980
+ type: "object",
3981
+ properties: {
3982
+ why: { type: "string", description: "One or two sentences on which you ticked and why." },
3983
+ calendars: {
3984
+ type: "array",
3985
+ items: {
3986
+ type: "object",
3987
+ properties: {
3988
+ title: { type: "string", description: "Exactly as Calendar.app spells it, or the connector's name for one Calendar.app lacks." },
3989
+ account: { type: "string" },
3990
+ include: { type: "boolean" },
3991
+ holiday: { type: "boolean" },
3992
+ briefName: { type: "string" },
3993
+ note: { type: "string" }
3994
+ },
3995
+ required: ["title", "account", "include", "holiday", "briefName", "note"],
3996
+ additionalProperties: false
3997
+ }
3998
+ }
3999
+ },
4000
+ required: ["why", "calendars"],
4001
+ additionalProperties: false
4002
+ }
4003
+ }
4004
+ };
4005
+ function suggestedObjectives(proposal) {
4006
+ const raw = Array.isArray(proposal?.suggestions) ? proposal.suggestions : [];
4007
+ return raw.flatMap((entry) => {
4008
+ const suggestion = typeof entry === "object" && entry !== null ? entry : {};
4009
+ const text = str3(suggestion.objective);
4010
+ return text ? [{ text, detail: str3(suggestion.evidence) || null }] : [];
4011
+ });
4012
+ }
4013
+ var objective = {
4014
+ id: "objective",
4015
+ topic: "your objective",
4016
+ title: () => "What are you trying to get done right now?",
4017
+ // Left empty, Continue says so; see `empty` on its card.
4018
+ later: null,
4019
+ card(ctx) {
4020
+ const proposal = proposalOf(ctx, "objective");
4021
+ return card({
4022
+ reply: "Tell it what you\u2019re working on, however roughly",
4023
+ empty: "Continue without one",
4024
+ why: str3(proposal?.why) || "The brief ranks the day against it. A next move works better than a project name. Tell it what you\u2019re working on, however roughly, and it will write it up.",
4025
+ fields: [
4026
+ field("objective", "Your objective", str3(proposal?.objective), {
4027
+ placeholder: "Get the staging deploy working again",
4028
+ suggestions: suggestedObjectives(proposal)
4029
+ }),
4030
+ field("blocker", "What\u2019s in the way, if anything", str3(proposal?.blocker), { placeholder: "Nobody can reproduce the failure" })
4031
+ ]
4032
+ });
3464
4033
  },
3465
- {
3466
- key: "DAILY_FOCUS_WORK_END",
3467
- group: "day",
3468
- label: "Working day ends at",
3469
- help: "Hour of the day, 0 to 23, when the brief doesn't say.",
3470
- kind: "number",
3471
- fallback: always("17")
4034
+ summary: (answer) => {
4035
+ const text = answer.fields.objective?.trim();
4036
+ const blocker = answer.fields.blocker?.trim();
4037
+ if (!text) return "No objective for now.";
4038
+ return `Your objective: ${text}${blocker ? `. In the way: ${blocker}` : ""}`;
3472
4039
  },
3473
- {
3474
- key: "DAILY_FOCUS_MIN_FREE_WINDOW",
3475
- group: "day",
3476
- label: "Shortest focus window",
3477
- help: "Minutes a gap has to last to count as time for focused work.",
3478
- kind: "number",
3479
- fallback: always("45 minutes")
4040
+ objective: (answer) => answer.fields.objective?.trim() ? { objective: answer.fields.objective.trim(), blocker: answer.fields.blocker?.trim() || null } : null,
4041
+ agent: {
4042
+ brief: (ctx) => `The user's current objective: the one thing they are trying to get done right now, which their ${ctx.profile} brief ranks the day against. Look at what they have been doing lately: their own recent and open pull requests, tickets assigned to them and in progress, and anything on the calendar that keeps coming back. Suggest up to three objectives, each only where there is a strong signal, with the evidence in a few words, such as "your 4 open pull requests on checkout". If nothing stands out, suggest none and say so. Leave \`objective\` and \`blocker\` empty until the user tells you what they are working on. When they do, write it up as one objective: a next move rather than a project name, in their own terms, under 12 words. Put what is in the way, if they mention anything, in \`blocker\`, as briefly.`,
4043
+ schema: {
4044
+ type: "object",
4045
+ properties: {
4046
+ why: { type: "string", description: "One or two sentences: what you looked at, or how you wrote up what they said." },
4047
+ objective: { type: "string" },
4048
+ blocker: { type: "string" },
4049
+ suggestions: {
4050
+ type: "array",
4051
+ items: {
4052
+ type: "object",
4053
+ properties: {
4054
+ objective: { type: "string" },
4055
+ evidence: { type: "string", description: "What it was drawn from, in a few words." }
4056
+ },
4057
+ required: ["objective", "evidence"],
4058
+ additionalProperties: false
4059
+ }
4060
+ }
4061
+ },
4062
+ required: ["why", "objective", "blocker", "suggestions"],
4063
+ additionalProperties: false
4064
+ }
3480
4065
  }
4066
+ };
4067
+ var QUESTIONS = [cli, profile, schedule, identity2, boards, calendars, objective];
4068
+ function questionById(id) {
4069
+ return QUESTIONS.find((question) => question.id === id);
4070
+ }
4071
+ function composeSources(profile2, sections, today) {
4072
+ const intro = [
4073
+ "# Sources",
4074
+ "",
4075
+ `Who you are and where to look, for your ${profile2 === "personal" ? "personal" : "work"} morning brief. Written by setup on ${today}; change it freely. The prompt next to this file (\`prompt.md\`) says *what* to gather and how to judge it; this file says *who* and *where*. A section that is missing is fine: the agent falls back to your primary calendar and connected accounts, and says what it missed.`
4076
+ ];
4077
+ return [...intro, ...sections.flatMap((section) => ["", `## ${section.heading}`, "", section.body])].join("\n") + "\n";
4078
+ }
4079
+ function machineName(platform = process.platform) {
4080
+ return platform === "darwin" ? "Mac" : platform === "win32" ? "PC" : "computer";
4081
+ }
4082
+
4083
+ // src/onboarding.ts
4084
+ var TURN_TIMEOUT_MS2 = 4 * 6e4;
4085
+ var KILL_AFTER_MS = 5e3;
4086
+ var STEPS_KEPT2 = 40;
4087
+ var SESSION_MODEL = {
4088
+ claude: { model: "opus", effort: "low" },
4089
+ codex: { model: null, effort: "low" }
4090
+ };
4091
+ var ONBOARDING_TOOLS = [
4092
+ "Bash(gh *)",
4093
+ "Bash(acli *)",
4094
+ "Bash(git config *)",
4095
+ "mcp__claude_ai_Gmail__search_threads",
4096
+ "mcp__claude_ai_Gmail__get_thread",
4097
+ "mcp__claude_ai_Gmail__get_message",
4098
+ "mcp__claude_ai_Gmail__list_labels",
4099
+ "mcp__claude_ai_Google_Calendar__list_calendars",
4100
+ "mcp__claude_ai_Google_Calendar__list_events",
4101
+ "mcp__claude_ai_Google_Calendar__search_events",
4102
+ "mcp__claude_ai_Google_Calendar__get_event",
4103
+ "mcp__claude_ai_Google_Drive__search_files",
4104
+ "mcp__claude_ai_Google_Drive__list_recent_files",
4105
+ "mcp__claude_ai_Google_Drive__get_file_metadata",
4106
+ "mcp__claude_ai_Google_Drive__read_file_content"
3481
4107
  ];
3482
- var BY_KEY = new Map(SETTINGS.map((def) => [def.key, def]));
3483
- function present(record, key) {
3484
- const value = record[key];
3485
- return value === void 0 || value.trim() === "" ? null : value.trim();
3486
- }
3487
- function profileOf(sources, settings = sources.settings) {
3488
- const raw = (present(layerEnv(sources, settings), "DAILY_FOCUS_PROFILE") ?? "work").toLowerCase();
3489
- return PROFILES.includes(raw) ? raw : "work";
3490
- }
3491
- function describeSettings(sources) {
3492
- const profile = profileOf(sources);
3493
- const settings = SETTINGS.map((def) => {
3494
- const fromEnv = present(sources.process, def.key);
3495
- const fromSettings = present(sources.settings, def.key);
3496
- const fromDotenv = present(sources.dotenv, def.key);
3497
- const [value, source] = fromEnv !== null ? [fromEnv, "environment"] : fromSettings !== null ? [fromSettings, "settings"] : fromDotenv !== null ? [fromDotenv, "dotenv"] : [null, "default"];
3498
- return {
3499
- key: def.key,
3500
- group: def.group,
3501
- label: def.label,
3502
- help: def.help,
3503
- kind: def.kind,
3504
- choices: def.choices ?? null,
3505
- advanced: def.advanced === true,
3506
- value,
3507
- source,
3508
- fallback: def.fallback(profile)
3509
- };
3510
- });
3511
- return { groups: SETTING_GROUPS, settings, error: sources.settingsError };
4108
+ var RESTARTED = "the dashboard restarted while this was running";
4109
+ function blankFile() {
4110
+ return { startedAt: (/* @__PURE__ */ new Date()).toISOString(), finishedAt: null, briefRequested: false, answers: {}, proposals: {} };
3512
4111
  }
3513
- function applySettingsChange(sources, change) {
3514
- const next = { ...sources.settings };
3515
- for (const [key, raw] of Object.entries(change)) {
3516
- if (LAUNCH_SETTINGS.includes(key)) {
3517
- return { error: `${key} says where the store is or how the server listens, so it is set in the environment, not here` };
4112
+ function parseFile(text) {
4113
+ const file = blankFile();
4114
+ let raw;
4115
+ try {
4116
+ raw = JSON.parse(text);
4117
+ } catch {
4118
+ return file;
4119
+ }
4120
+ if (typeof raw !== "object" || raw === null) return file;
4121
+ const obj = raw;
4122
+ if (typeof obj.startedAt === "string") file.startedAt = obj.startedAt;
4123
+ if (typeof obj.finishedAt === "string") file.finishedAt = obj.finishedAt;
4124
+ file.briefRequested = obj.briefRequested === true;
4125
+ if (typeof obj.answers === "object" && obj.answers !== null) {
4126
+ for (const [id, answer] of Object.entries(obj.answers)) {
4127
+ const clean = cleanAnswer(answer);
4128
+ if (clean) file.answers[id] = clean;
3518
4129
  }
3519
- if (!BY_KEY.has(key)) return { error: `${key} is not a setting` };
3520
- if (raw !== null && typeof raw !== "string") return { error: `${key} must be text, or null for the default` };
3521
- if (present(sources.process, key) !== null) {
3522
- return { error: `${key} is set in the environment the dashboard was started with, which wins over this page` };
4130
+ }
4131
+ if (typeof obj.proposals === "object" && obj.proposals !== null) {
4132
+ for (const [id, value] of Object.entries(obj.proposals)) {
4133
+ const record = value;
4134
+ if (!record || !CLI_NAMES.includes(record.cli) || !PROFILES.includes(record.profile)) continue;
4135
+ file.proposals[id] = {
4136
+ cli: record.cli,
4137
+ profile: record.profile,
4138
+ sessionId: typeof record.sessionId === "string" ? record.sessionId : null,
4139
+ status: record.status === "ready" || record.status === "failed" ? record.status : "working",
4140
+ result: record.result ?? null,
4141
+ error: typeof record.error === "string" ? record.error : null,
4142
+ version: typeof record.version === "number" ? record.version : 0,
4143
+ conversation: Array.isArray(record.conversation) ? record.conversation.filter(
4144
+ (line) => typeof line === "object" && line !== null && (line.from === "you" || line.from === "it") && typeof line.text === "string"
4145
+ ) : []
4146
+ };
3523
4147
  }
3524
- const value = raw?.trim() ?? "";
3525
- if (value === "") delete next[key];
3526
- else next[key] = value;
3527
4148
  }
3528
- try {
3529
- loadConfig(layerEnv(sources, next));
3530
- } catch (error) {
3531
- return { error: error instanceof Error ? error.message : String(error) };
4149
+ return file;
4150
+ }
4151
+ function cleanAnswer(value) {
4152
+ if (typeof value !== "object" || value === null) return null;
4153
+ const obj = value;
4154
+ if (obj.later === true) return { later: true };
4155
+ const choices = {};
4156
+ const fields = {};
4157
+ if (typeof obj.choices === "object" && obj.choices !== null) {
4158
+ for (const [id, values] of Object.entries(obj.choices)) {
4159
+ if (Array.isArray(values)) choices[id] = [...new Set(values.filter((v) => typeof v === "string" && v.trim() !== ""))];
4160
+ }
3532
4161
  }
3533
- return { settings: next };
4162
+ if (typeof obj.fields === "object" && obj.fields !== null) {
4163
+ for (const [id, text] of Object.entries(obj.fields)) {
4164
+ if (typeof text === "string") fields[id] = text;
4165
+ }
4166
+ }
4167
+ return { choices, fields };
3534
4168
  }
3535
- async function saveSettings(path, settings) {
3536
- const sorted = {};
3537
- for (const key of Object.keys(settings).sort()) sorted[key] = settings[key];
3538
- await writeJsonAtomic(path, sorted);
4169
+ function given(answer) {
4170
+ return answer && answer.later !== true ? answer : null;
3539
4171
  }
4172
+ var Onboarding = class {
4173
+ file = null;
4174
+ loaded = false;
4175
+ running = /* @__PURE__ */ new Map();
4176
+ steps = /* @__PURE__ */ new Map();
4177
+ /** Calendar.app's calendars being listed, which only starting the sessions waits for. */
4178
+ calendars = null;
4179
+ /** What listing them found, once it has: the calendars, or why they couldn't be listed. */
4180
+ listed = null;
4181
+ finishError = null;
4182
+ config;
4183
+ deps;
4184
+ spawnFn;
4185
+ gates = /* @__PURE__ */ new Map();
4186
+ codexOff = null;
4187
+ codexModel = null;
4188
+ /** What looking for the CLIs found, once the screen has asked. Not kept: what is installed changes. */
4189
+ detected = null;
4190
+ detecting = null;
4191
+ /** The pass starting sessions, which the next one waits for; see `startProposals`. */
4192
+ starting = Promise.resolve();
4193
+ /** The write in progress, which the next one waits for. */
4194
+ writes = Promise.resolve();
4195
+ constructor(config, deps) {
4196
+ this.config = config;
4197
+ this.deps = deps;
4198
+ this.spawnFn = deps.spawnFn ?? spawn2;
4199
+ }
4200
+ /** Whether a session has a process going. A restart waits for none. */
4201
+ get busy() {
4202
+ return this.running.size > 0;
4203
+ }
4204
+ /**
4205
+ * Read the file, and start again any session the last process left going.
4206
+ * Only while setup is still to do: `needed` is the caller's `firstBriefPending`.
4207
+ */
4208
+ async start(needed) {
4209
+ await this.load();
4210
+ if (!needed || this.file?.finishedAt) return;
4211
+ for (const record of Object.values(this.file?.proposals ?? {})) {
4212
+ if (record.status === "working") {
4213
+ record.status = "failed";
4214
+ record.error = RESTARTED;
4215
+ }
4216
+ }
4217
+ this.startProposals();
4218
+ }
4219
+ /** The screen, while setup is to do: the store has never had a brief, and setup hasn't finished. */
4220
+ async view(setup) {
4221
+ await this.load();
4222
+ if (!setup.needed || this.file?.finishedAt) return null;
4223
+ const file = this.file ?? blankFile();
4224
+ const ctx = await this.context({ setup });
4225
+ const answers = this.effective(ctx, file.answers);
4226
+ const steps = [];
4227
+ for (const question of this.questions(ctx)) {
4228
+ const drawn = question.card(ctx);
4229
+ const record = file.proposals[question.id];
4230
+ const answer = answers[question.id] ?? null;
4231
+ const chosen = given(answer);
4232
+ const looking = question.id === "cli" && !answer && !this.detected;
4233
+ steps.push({
4234
+ id: question.id,
4235
+ title: question.title(ctx),
4236
+ topic: question.topic,
4237
+ status: answer ? chosen ? "answered" : "later" : "open",
4238
+ summary: chosen ? question.summary(chosen, ctx) : answer ? question.later?.summary ?? null : null,
4239
+ card: { ...drawn, version: record?.version ?? 0, revisable: Boolean(question.agent && record), later: question.later?.label ?? null },
4240
+ proposal: looking ? {
4241
+ status: "working",
4242
+ conversation: [],
4243
+ steps: [{ kind: "tool", text: "Look for Codex and Claude Code", detail: null, state: "running" }],
4244
+ error: null
4245
+ } : record ? {
4246
+ status: record.status,
4247
+ conversation: record.conversation,
4248
+ steps: this.steps.get(question.id) ?? [],
4249
+ error: record.status === "failed" ? record.error : null
4250
+ } : null,
4251
+ answer
4252
+ });
4253
+ }
4254
+ return {
4255
+ detected: this.detected !== null,
4256
+ steps,
4257
+ current: steps.find((step) => step.status === "open")?.id ?? null,
4258
+ error: this.finishError
4259
+ };
4260
+ }
4261
+ /**
4262
+ * Look for the CLIs, which the screen asks for when it opens: with one ready,
4263
+ * the first question answers itself. Asked again by Check again.
4264
+ */
4265
+ async detect() {
4266
+ await this.load();
4267
+ await this.lookForClis(true);
4268
+ this.startProposals();
4269
+ this.deps.onChange();
4270
+ }
4271
+ /**
4272
+ * Record an answer, or that the user would rather decide later. One whose
4273
+ * settings the dashboard would refuse is refused here, while the card that
4274
+ * can fix it is still open.
4275
+ */
4276
+ async answer(id, raw) {
4277
+ await this.load();
4278
+ const file = this.file ??= blankFile();
4279
+ if (file.finishedAt) return "setup has finished";
4280
+ const question = questionById(id);
4281
+ let answer = cleanAnswer(raw);
4282
+ if (!question || !answer) return `there is no question ${JSON.stringify(id)}, or that is not an answer to it`;
4283
+ if (!given(answer) && !question.later) return `"${question.topic}" needs an answer`;
4284
+ if (id === "cli" && given(answer)) {
4285
+ const chosen = await this.chooseCli(given(answer));
4286
+ if (typeof chosen === "string") return chosen;
4287
+ answer = chosen;
4288
+ }
4289
+ const ctx = await this.context();
4290
+ const before = { cli: ctx.cli, profile: ctx.profile };
4291
+ if (given(answer) && question.settings) {
4292
+ const refused = this.deps.checkSettings(this.settingsOf({ ...file.answers, [id]: answer }, await this.context({ answers: { ...file.answers, [id]: answer } })));
4293
+ if (refused) return refused;
4294
+ }
4295
+ file.answers[id] = answer;
4296
+ this.finishError = null;
4297
+ await this.stop(id, "you continued before it finished");
4298
+ const after = await this.context();
4299
+ if (after.profile !== before.profile) {
4300
+ for (const other of QUESTIONS) {
4301
+ if (!other.agent) continue;
4302
+ delete file.answers[other.id];
4303
+ await this.drop(other.id);
4304
+ }
4305
+ } else if (after.cli !== before.cli) {
4306
+ for (const other of QUESTIONS) if (other.agent && !file.answers[other.id]) await this.drop(other.id);
4307
+ }
4308
+ await this.save();
4309
+ this.startProposals();
4310
+ this.deps.onChange();
4311
+ return null;
4312
+ }
4313
+ /** Tell a question's session what to change. `draft` is what the card holds now, which it should start from. */
4314
+ async revise(id, text, draft) {
4315
+ await this.load();
4316
+ const question = questionById(id);
4317
+ const record = this.file?.proposals[id];
4318
+ if (!question?.agent || !record) return "nothing is looking into that question";
4319
+ if (this.running.has(id)) return "it is still working on that question";
4320
+ const ctx = await this.context();
4321
+ const current = cleanAnswer(draft);
4322
+ const request = [
4323
+ `The user says: ${text.trim()}`,
4324
+ ...given(current) ? ["", "What the card holds now, which they may have changed:", "", "```json", JSON.stringify(current), "```"] : [],
4325
+ "",
4326
+ "Revise your proposal, and answer with the whole of it again."
4327
+ ].join("\n");
4328
+ const resume = record.sessionId && record.cli === ctx.cli ? record.sessionId : null;
4329
+ const said = [...record.conversation, { from: "you", text: text.trim() }];
4330
+ await this.ask(question, ctx, resume ? request : `${await this.firstMessage(question, ctx)}
3540
4331
 
3541
- // src/setup.ts
3542
- import { execFile as execFile4 } from "node:child_process";
3543
- import { access, constants, readdir as readdir2, readFile as readFile7, stat as stat3 } from "node:fs/promises";
3544
- import { delimiter, isAbsolute, join as join3 } from "node:path";
3545
- import { promisify as promisify4 } from "node:util";
3546
- var run4 = promisify4(execFile4);
3547
- var PLACEHOLDERS = [
3548
- "Your Name",
3549
- "you@example.com",
3550
- "you@personal.example",
3551
- "your-github-login",
3552
- "Some Shared Calendar",
3553
- "Some Noisy Calendar",
3554
- "<where your colleagues are>",
3555
- "<where you are>",
3556
- "<where you live>",
3557
- "<document id>"
3558
- ];
3559
- function placeholdersIn(text) {
3560
- return PLACEHOLDERS.filter((placeholder) => text.includes(placeholder)).length;
3561
- }
3562
- async function findExecutable(name, path = process.env.PATH ?? "") {
3563
- const candidates = isAbsolute(name) ? [name] : path.split(delimiter).filter(Boolean).map((dir) => join3(dir, name));
3564
- for (const candidate of candidates) {
4332
+ ${request}`, resume, said);
4333
+ return null;
4334
+ }
4335
+ /**
4336
+ * Look again: ask a question's session again from the start, after it failed,
4337
+ * and list Calendar.app's calendars again for the question that shows them,
4338
+ * since what failed may have been the access the user has since granted.
4339
+ */
4340
+ async retry(id) {
4341
+ await this.load();
4342
+ const question = questionById(id);
4343
+ if (!question) return `there is no question ${JSON.stringify(id)}`;
4344
+ if (this.running.has(id)) return "it is still working on that question";
4345
+ if (id === "calendars") {
4346
+ this.calendars = null;
4347
+ this.listed = null;
4348
+ }
4349
+ if (id === "cli") await this.lookForClis(true);
4350
+ if (question.agent) await this.drop(id);
4351
+ this.startProposals();
4352
+ this.deps.onChange();
4353
+ return null;
4354
+ }
4355
+ /**
4356
+ * Save everything, and ask for the first brief. Files first, then the
4357
+ * settings, whose save restarts the dashboard: the start after it begins the
4358
+ * run. Resolves with why not, or null.
4359
+ */
4360
+ async finish() {
4361
+ await this.load();
4362
+ const file = this.file ??= blankFile();
4363
+ if (file.finishedAt) return { error: "setup has already finished" };
4364
+ if (!file.answers.cli) await this.lookForClis(false);
4365
+ const ctx = await this.context();
4366
+ const answers = this.effective(ctx, file.answers);
4367
+ const sections = [];
4368
+ let objective2 = null;
4369
+ for (const question of this.questions(ctx)) {
4370
+ const answer = answers[question.id];
4371
+ if (!answer) return this.refuse(`answer "${question.title(ctx)}", or decide it later`);
4372
+ const chosen = given(answer);
4373
+ if (!chosen) continue;
4374
+ sections.push(...question.sources?.(chosen, ctx) ?? []);
4375
+ objective2 = question.objective?.(chosen) ?? objective2;
4376
+ }
4377
+ const values = this.settingsOf(file.answers, ctx);
4378
+ const refused = this.deps.checkSettings(values);
4379
+ if (refused) return this.refuse(refused);
4380
+ await this.starting;
4381
+ for (const id of [...this.running.keys()]) await this.stop(id, "setup finished");
4382
+ const existing = await readEditable(this.config.sourcesFile);
4383
+ if (existing.text === null && sections.length > 0) {
4384
+ await mkdir2(this.config.dataDir, { recursive: true });
4385
+ await saveEditable(this.config.sourcesFile, composeSources(ctx.profile, sections, (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)), ABSENT);
4386
+ }
4387
+ if (objective2) {
4388
+ const focus = await readEditable(this.config.focusFile);
4389
+ const saved = await saveEditable(this.config.focusFile, setFocusFields(focus.text, objective2, FOCUS_TEMPLATE), focus.version);
4390
+ if (!saved.saved) return this.refuse("focus.md changed on disk while saving; finish again");
4391
+ }
4392
+ file.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
4393
+ file.briefRequested = ctx.cli !== null;
4394
+ await this.save();
4395
+ const before = JSON.stringify(Object.entries(this.deps.readSources().settings).sort());
4396
+ const saveRefused = await this.deps.saveSettings(values);
4397
+ if (saveRefused) {
4398
+ file.finishedAt = null;
4399
+ file.briefRequested = false;
4400
+ await this.save();
4401
+ return this.refuse(saveRefused);
4402
+ }
4403
+ this.finishError = null;
4404
+ this.deps.onChange();
4405
+ return { restart: JSON.stringify(Object.entries(this.deps.readSources().settings).sort()) !== before };
4406
+ }
4407
+ /**
4408
+ * Whether the finish asked for a first brief that hasn't been started, and
4409
+ * forget it: asked once, so a run that fails is retried by the user, not by
4410
+ * every start.
4411
+ */
4412
+ async takeBriefRequest() {
4413
+ await this.load();
4414
+ if (!this.file?.briefRequested) return false;
4415
+ this.file.briefRequested = false;
4416
+ await this.save();
4417
+ return true;
4418
+ }
4419
+ /** What the finish would write as the source list, or null when the store already has one it would keep. */
4420
+ async previewSources() {
4421
+ await this.load();
4422
+ const existing = await readEditable(this.config.sourcesFile);
4423
+ if (existing.text !== null) return { text: null, kept: true };
4424
+ const ctx = await this.context();
4425
+ const sections = [];
4426
+ for (const question of this.questions(ctx)) {
4427
+ const chosen = given(this.file?.answers[question.id]);
4428
+ if (chosen) sections.push(...question.sources?.(chosen, ctx) ?? []);
4429
+ }
4430
+ return { text: sections.length ? composeSources(ctx.profile, sections, (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)) : null, kept: false };
4431
+ }
4432
+ /** Stop every session, for shutdown, and wait until each has exited and been written down. */
4433
+ async close() {
4434
+ await this.starting;
4435
+ const over = [...this.running.values()].map((run5) => run5.over);
4436
+ for (const id of [...this.running.keys()]) await this.stop(id, RESTARTED);
4437
+ await Promise.all(over);
4438
+ }
4439
+ /* ---------- the sessions ---------- */
4440
+ /**
4441
+ * Start whatever sessions the answers so far call for, without holding up the
4442
+ * reply that asked: starting one under Codex runs a few checks first, and a
4443
+ * screen waiting on them has every button greyed out with nothing to say why.
4444
+ * One pass at a time, so two quick answers can't both start the same question.
4445
+ */
4446
+ startProposals() {
4447
+ this.starting = this.starting.then(() => this.ensureProposals()).catch((err) => console.error(`[daily-focus] setup could not start looking: ${err.message}`));
4448
+ }
4449
+ /** Resolves once the sessions asked for so far have started: for tests, and for anything that must not race them. */
4450
+ started() {
4451
+ return this.starting;
4452
+ }
4453
+ /** Start a session for each question that has one and needs it: the CLI and the profile are known, and nobody has asked yet. */
4454
+ async ensureProposals() {
4455
+ const file = this.file;
4456
+ if (!file || file.finishedAt) return;
4457
+ if (Object.keys(file.answers).length === 0) return;
4458
+ if (file.answers.cli && !given(file.answers.cli)) return;
4459
+ await this.lookForClis(false);
4460
+ const ctx = await this.context({ listCalendars: true });
4461
+ if (!ctx.cli || given(file.answers.profile) === null) return;
4462
+ for (const question of this.questions(ctx)) {
4463
+ if (!question.agent || file.answers[question.id] || this.running.has(question.id)) continue;
4464
+ const record = file.proposals[question.id];
4465
+ if (record && record.cli === ctx.cli && record.profile === ctx.profile && record.status !== "failed") continue;
4466
+ if (record?.status === "failed" && record.error !== RESTARTED && record.cli === ctx.cli && record.profile === ctx.profile) continue;
4467
+ await this.ask(question, ctx, await this.firstMessage(question, ctx), null);
4468
+ }
4469
+ }
4470
+ /** The first message of a question's session: the instructions, what is known, and the question. */
4471
+ async firstMessage(question, ctx) {
4472
+ const instructions = await readFile8(this.config.onboardingPromptSource, "utf8").catch(
4473
+ () => "You are helping someone set up Daily Focus. Only read; never send, post or edit anything. Answer with the JSON the schema asks for."
4474
+ );
4475
+ const agent = question.agent;
4476
+ const facts = agent.facts?.(ctx) ?? null;
4477
+ return [
4478
+ instructions.trim(),
4479
+ "",
4480
+ "## This setup",
4481
+ "",
4482
+ `- Profile: ${ctx.profile}`,
4483
+ `- Today: ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`,
4484
+ "",
4485
+ `## The question: ${question.title(ctx)}`,
4486
+ "",
4487
+ agent.brief(ctx),
4488
+ ...facts ? ["", "### What the dashboard already knows", "", facts] : []
4489
+ ].join("\n");
4490
+ }
4491
+ async ask(question, ctx, prompt, resume, conversation = []) {
4492
+ const file = this.file ??= blankFile();
4493
+ const cli2 = ctx.cli;
4494
+ const previous = file.proposals[question.id];
4495
+ const record = {
4496
+ cli: cli2,
4497
+ profile: ctx.profile,
4498
+ sessionId: resume,
4499
+ status: "working",
4500
+ result: previous?.cli === cli2 && previous.profile === ctx.profile ? previous.result : null,
4501
+ error: null,
4502
+ version: previous?.version ?? 0,
4503
+ conversation
4504
+ };
4505
+ file.proposals[question.id] = record;
4506
+ this.steps.set(question.id, []);
4507
+ await this.save();
4508
+ this.deps.onChange();
4509
+ const bin = ctx.clis?.[cli2]?.path ?? null;
4510
+ if (!bin) return this.settle(question.id, record, { ok: false, error: `could not find ${cli2} on this machine` });
4511
+ if (cli2 === "codex") {
4512
+ const gate = this.gates.get(bin) ?? new CodexGate(bin, this.deps.execFn);
4513
+ this.gates.set(bin, gate);
4514
+ const refusal2 = await gate.refusal();
4515
+ if (refusal2) return this.settle(question.id, record, { ok: false, error: refusal2 });
4516
+ }
4517
+ let codexConfig = [];
4518
+ if (cli2 === "codex") {
4519
+ try {
4520
+ this.codexOff ??= computerUseOff(bin, this.deps.execFn);
4521
+ codexConfig = await this.codexOff;
4522
+ } catch (err) {
4523
+ this.codexOff = null;
4524
+ return this.settle(question.id, record, { ok: false, error: `Codex could not say which plugins to switch off: ${err.message}` });
4525
+ }
4526
+ }
4527
+ const schemaDir = await mkdtemp(join6(tmpdir2(), "daily-focus-setup-"));
4528
+ const schemaFile = join6(schemaDir, "schema.json");
4529
+ const json = JSON.stringify(question.agent.schema);
4530
+ await writeFile2(schemaFile, json, "utf8");
4531
+ await mkdir2(this.config.onboardingDir, { recursive: true });
4532
+ const adapter = ADAPTERS[cli2];
4533
+ const session = SESSION_MODEL[cli2];
4534
+ const model = cli2 === "codex" ? await (this.codexModel ??= codexLatestModel(bin, this.deps.execFn)) : session.model;
4535
+ const args = adapter.args({
4536
+ prompt,
4537
+ sessionId: resume,
4538
+ model,
4539
+ effort: session.effort,
4540
+ tools: ONBOARDING_TOOLS,
4541
+ denyEdits: true,
4542
+ drafts: false,
4543
+ codexConfig,
4544
+ schema: { json, file: schemaFile }
4545
+ });
4546
+ let child;
3565
4547
  try {
3566
- await access(candidate, constants.X_OK);
3567
- if ((await stat3(candidate)).isFile()) return candidate;
3568
- } catch {
4548
+ child = this.spawnFn(bin, args, { cwd: this.config.onboardingDir, stdio: ["ignore", "pipe", "pipe"], env: process.env });
4549
+ } catch (err) {
4550
+ await rm2(schemaDir, { recursive: true, force: true });
4551
+ return this.settle(question.id, record, { ok: false, error: `could not run ${bin}: ${err.message}` });
4552
+ }
4553
+ const timer = setTimeout(() => void this.stop(question.id, "gave up after four minutes"), TURN_TIMEOUT_MS2);
4554
+ timer.unref();
4555
+ let ended;
4556
+ const run5 = { child, timer, abortReason: null, over: new Promise((resolve2) => ended = resolve2) };
4557
+ this.running.set(question.id, run5);
4558
+ let done;
4559
+ let said = "";
4560
+ const stderr = [];
4561
+ child.stderr?.on("data", (chunk) => {
4562
+ stderr.push(chunk.toString("utf8"));
4563
+ if (stderr.length > 50) stderr.shift();
4564
+ });
4565
+ const calls = /* @__PURE__ */ new Map();
4566
+ const steps = this.steps.get(question.id);
4567
+ const add = (step) => {
4568
+ steps.push(step);
4569
+ if (steps.length > STEPS_KEPT2) steps.shift();
4570
+ };
4571
+ if (child.stdout) {
4572
+ createInterface2({ input: child.stdout }).on("line", (line) => {
4573
+ const event = adapter.parse(line);
4574
+ if (!event) return;
4575
+ if (event.sessionId) record.sessionId = event.sessionId;
4576
+ if (event.text) said = event.text;
4577
+ for (const parsed of event.steps ?? []) {
4578
+ const known = calls.get(parsed.id);
4579
+ if (known) {
4580
+ if (parsed.text) known.text = parsed.text;
4581
+ if (parsed.detail) known.detail = parsed.detail;
4582
+ known.state = parsed.state;
4583
+ } else if (parsed.text) {
4584
+ const step = { kind: "tool", text: parsed.text, detail: parsed.detail ?? null, state: parsed.state };
4585
+ calls.set(parsed.id, step);
4586
+ add(step);
4587
+ }
4588
+ }
4589
+ if (event.steps) this.deps.onChange();
4590
+ if (event.done) done = event.done;
4591
+ });
3569
4592
  }
4593
+ child.on("error", (err) => {
4594
+ done = { ok: false, error: `could not run ${bin}: ${err.message}` };
4595
+ });
4596
+ child.on("exit", () => {
4597
+ if (run5.abortReason === null) return;
4598
+ child.stdout?.destroy();
4599
+ child.stderr?.destroy();
4600
+ });
4601
+ child.on("close", (code) => {
4602
+ clearTimeout(timer);
4603
+ this.running.delete(question.id);
4604
+ void rm2(schemaDir, { recursive: true, force: true });
4605
+ const outcome = run5.abortReason ? { ok: false, error: run5.abortReason } : done ?? {
4606
+ ok: false,
4607
+ error: `${cli2} exited with code ${code}${stderr.length ? `: ${stderr.join("").trim().split("\n").slice(-3).join(" ")}` : ""}`
4608
+ };
4609
+ void this.settle(question.id, record, outcome.ok ? { ok: true, reply: outcome.reply ?? said } : outcome).catch((err) => console.error(`[daily-focus] setup could not keep what it found: ${err.message}`)).finally(ended);
4610
+ });
3570
4611
  }
3571
- return null;
3572
- }
3573
- async function findClis(config) {
3574
- const found = {};
3575
- for (const cli of CLI_NAMES) {
3576
- const candidates = [
3577
- config.agent.cli === cli ? config.agent.binPath : null,
3578
- config.assistant.agent === cli ? config.assistant.binPath : null,
3579
- cli
3580
- ].filter((candidate) => candidate !== null);
3581
- found[cli] = null;
3582
- for (const candidate of candidates) {
3583
- found[cli] = await findExecutable(candidate);
3584
- if (found[cli]) break;
4612
+ /** A session is over: keep its answer when it is JSON, or why it isn't. */
4613
+ async settle(id, record, outcome) {
4614
+ if (this.file?.proposals[id] !== record) return;
4615
+ for (const step of this.steps.get(id) ?? []) if (step.state === "running") step.state = outcome.ok ? "done" : "failed";
4616
+ if (outcome.ok) {
4617
+ try {
4618
+ record.result = JSON.parse(stripFence(outcome.reply ?? ""));
4619
+ record.status = "ready";
4620
+ record.error = null;
4621
+ record.version += 1;
4622
+ const why = record.result?.why;
4623
+ if (typeof why === "string" && why.trim()) record.conversation.push({ from: "it", text: why.trim() });
4624
+ } catch {
4625
+ record.status = "failed";
4626
+ record.error = "it answered, but not in the shape asked for";
4627
+ }
4628
+ } else {
4629
+ record.status = "failed";
4630
+ record.error = outcome.error ?? "failed";
3585
4631
  }
4632
+ await this.save();
4633
+ this.deps.onChange();
4634
+ }
4635
+ /** Stop a session: asked to, then made to if it hasn't gone within a few seconds. */
4636
+ async stop(id, reason) {
4637
+ const run5 = this.running.get(id);
4638
+ if (!run5 || run5.abortReason !== null) return;
4639
+ run5.abortReason = reason;
4640
+ run5.child.kill("SIGTERM");
4641
+ const kill = setTimeout(() => {
4642
+ if (run5.child.exitCode === null && run5.child.signalCode === null) run5.child.kill("SIGKILL");
4643
+ }, KILL_AFTER_MS);
4644
+ kill.unref();
4645
+ void run5.over.then(() => clearTimeout(kill));
4646
+ }
4647
+ /** Forget a question's proposal, stopping its session first. */
4648
+ async drop(id) {
4649
+ await this.stop(id, "no longer needed");
4650
+ if (this.file) delete this.file.proposals[id];
4651
+ this.steps.delete(id);
4652
+ }
4653
+ /* ---------- what a question may read ---------- */
4654
+ /**
4655
+ * Look for the CLIs, once per process unless `again`. Only from a click or
4656
+ * from setup already under way: `--version` and a login check are programs
4657
+ * run on the user's behalf, and a new store's state is read before anyone is
4658
+ * at the screen.
4659
+ */
4660
+ async lookForClis(again) {
4661
+ if (this.detected && !again) return;
4662
+ this.detecting ??= this.deps.detectClis().finally(() => {
4663
+ this.detecting = null;
4664
+ });
4665
+ this.detected = await this.detecting;
3586
4666
  }
3587
- return found;
3588
- }
3589
- async function hasArchive(config) {
3590
- try {
3591
- return (await readdir2(config.archiveDir)).some((name) => name.endsWith(".json"));
3592
- } catch {
3593
- return false;
4667
+ /**
4668
+ * The CLI answer to keep: one of the ready ones, or a path the user typed,
4669
+ * which is used only once it has said what it is and that it is ready.
4670
+ * Otherwise why not, in a sentence the card shows.
4671
+ */
4672
+ async chooseCli(answer) {
4673
+ const typed = answer.fields.path?.trim();
4674
+ if (!typed) {
4675
+ const picked = answer.choices.cli?.[0];
4676
+ await this.lookForClis(false);
4677
+ if (picked && this.detected?.[picked]?.ready) return { choices: { cli: [picked] }, fields: {} };
4678
+ return "Enter the path to codex or claude, or decide this later";
4679
+ }
4680
+ const { cli: cli2, found } = await this.deps.probeCli(expandHome(typed));
4681
+ if (!cli2 || !found.ready) {
4682
+ return `${found.problem ?? "That can't be used"}${found.fix ? `: run ${found.fix} in a terminal, then try again.` : "."}`;
4683
+ }
4684
+ await this.lookForClis(false);
4685
+ this.detected = { ...this.detected, [cli2]: found };
4686
+ return { choices: { cli: [cli2] }, fields: { path: found.path } };
3594
4687
  }
3595
- }
3596
- async function exists(path) {
3597
- try {
3598
- await stat3(path);
3599
- return true;
3600
- } catch {
3601
- return false;
4688
+ /**
4689
+ * The answers as they stand, with the CLI setup picked without asking when
4690
+ * the user hasn't picked one: the first ready, or the one already set.
4691
+ */
4692
+ effective(ctx, answers) {
4693
+ if (answers.cli || !ctx.cli) return { ...answers };
4694
+ return { ...answers, cli: { choices: { cli: [ctx.cli] }, fields: {} } };
4695
+ }
4696
+ /** The settings these answers add up to, in question order. */
4697
+ settingsOf(answers, ctx) {
4698
+ const values = {};
4699
+ const effective = this.effective(ctx, answers);
4700
+ for (const question of this.questions(ctx)) {
4701
+ const chosen = given(effective[question.id]);
4702
+ if (chosen) Object.assign(values, question.settings?.(chosen, ctx) ?? {});
4703
+ }
4704
+ return values;
3602
4705
  }
3603
- }
3604
- async function firstBriefPending(config) {
3605
- return !await exists(config.itemsFile) && !await hasArchive(config);
3606
- }
3607
- async function setupState(config, opts) {
3608
- let sources = null;
3609
- try {
3610
- sources = await readFile7(config.sourcesFile, "utf8");
3611
- } catch {
3612
- sources = null;
4706
+ questions(ctx) {
4707
+ return QUESTIONS.filter((question) => !question.applies || question.applies(ctx));
3613
4708
  }
3614
- const supported = process.platform === "darwin";
3615
- return {
3616
- // The file, not whether it parsed: a brief that is there but broken is a
3617
- // problem for its banner to report, not a store to set up from scratch. The
3618
- // agent's clock asks the same question.
3619
- needed: await firstBriefPending(config),
3620
- profile: config.profile,
3621
- profileChosen: opts.profileChosen,
3622
- sources: { exists: sources !== null, placeholders: sources === null ? 0 : placeholdersIn(sources) },
3623
- clis: await findClis(config),
3624
- calendar: {
3625
- supported,
3626
- built: supported && await exists(config.calendar.appPath),
3627
- chosen: config.calendar.names.length,
3628
- buildCommand: command("build-calendar")
4709
+ /**
4710
+ * Everything a question may read. The CLI and the profile are the answers
4711
+ * when given, else what is in effect, else the card's own pick, so the
4712
+ * questions that follow from them are counted from the start.
4713
+ *
4714
+ * Calendar.app's calendars only as already listed, unless `listCalendars`:
4715
+ * the helper may ask for calendar access, so it is launched only from a
4716
+ * click, never from a state read, which any GET makes.
4717
+ */
4718
+ async context(opts = {}) {
4719
+ const values = layerSettings(this.deps.readSources());
4720
+ const current = {};
4721
+ for (const def of SETTINGS) {
4722
+ const value = (values[def.key] ?? "").trim();
4723
+ if (value) current[def.key] = value;
4724
+ }
4725
+ const setup = opts.setup ?? await this.deps.setup();
4726
+ const answers = opts.answers ?? this.file?.answers ?? {};
4727
+ const profileAnswer = given(answers.profile)?.choices.profile?.[0];
4728
+ const profile2 = PROFILES.includes(profileAnswer) ? profileAnswer : current.DAILY_FOCUS_PROFILE === "personal" ? "personal" : "work";
4729
+ const cliAnswer = given(answers.cli);
4730
+ const typed = cliAnswer?.fields.path?.trim();
4731
+ const answered = cliAnswer?.choices.cli?.[0];
4732
+ let clis = this.detected;
4733
+ if (clis && typed && answered && CLI_NAMES.includes(answered) && clis[answered].path !== typed) {
4734
+ clis = { ...clis, [answered]: { ...clis[answered], path: typed, onPath: false, ready: true, problem: null, fix: null } };
4735
+ }
4736
+ const set = current.DAILY_FOCUS_AGENT;
4737
+ const cli2 = answers.cli ? answered ?? null : set && clis?.[set]?.ready ? set : clis ? preferredCli(clis) : null;
4738
+ const started = Object.keys(answers).length > 0;
4739
+ const profileKnown = started && given(answers.profile) !== null;
4740
+ let calendars2 = null;
4741
+ const liveAgenda = !["off", "false", "0", "no"].includes((current.DAILY_FOCUS_CALENDAR ?? "").toLowerCase());
4742
+ if (setup.calendar.supported && setup.calendar.built && liveAgenda) {
4743
+ if (opts.listCalendars === true && profileKnown) {
4744
+ this.calendars ??= this.deps.listCalendars().catch((err) => err instanceof Error ? err.message : String(err)).then((listed2) => {
4745
+ this.listed = listed2;
4746
+ });
4747
+ await this.calendars;
4748
+ }
4749
+ calendars2 = this.listed;
4750
+ }
4751
+ const proposals = {};
4752
+ for (const [id, record] of Object.entries(this.file?.proposals ?? {})) {
4753
+ if (record.cli === cli2 && record.profile === profile2 && record.result !== null) proposals[id] = record.result;
3629
4754
  }
3630
- };
3631
- }
3632
- var identity = null;
3633
- function detectIdentity(config) {
3634
- identity ??= (async () => {
3635
- const [user, email] = await Promise.all([
3636
- run4(config.github.ghPath, ["api", "user", "--jq", '[.login, .name // ""] | join("\\n")'], { timeout: 5e3 }).then(
3637
- ({ stdout }) => stdout.split("\n"),
3638
- () => []
3639
- ),
3640
- run4("git", ["config", "--global", "user.email"], { timeout: 2e3 }).then(
3641
- ({ stdout }) => stdout.trim() || null,
3642
- () => null
3643
- )
3644
- ]);
3645
4755
  return {
3646
- login: config.github.accounts[0] ?? (user[0]?.trim() || null),
3647
- name: user[1]?.trim() || null,
3648
- email
4756
+ machine: machineName(),
4757
+ profile: profile2,
4758
+ cli: cli2 && CLI_NAMES.includes(cli2) ? cli2 : null,
4759
+ clis,
4760
+ identity: started ? await this.deps.identity() : { name: null, login: null, logins: [], email: null },
4761
+ calendars: calendars2,
4762
+ calendar: setup.calendar,
4763
+ boards: await this.deps.boards(),
4764
+ current,
4765
+ proposals
3649
4766
  };
3650
- })();
3651
- return identity;
3652
- }
3653
- function fillIdentity(template, who) {
3654
- let text = template;
3655
- if (who.name) text = text.replaceAll("`Your Name`", `\`${who.name}\``);
3656
- if (who.email) text = text.replaceAll("`you@example.com`", `\`${who.email}\``).replaceAll("`you@personal.example`", `\`${who.email}\``);
3657
- if (who.login) text = text.replaceAll("`your-github-login`", `\`${who.login}\``);
3658
- return text;
4767
+ }
4768
+ /* ---------- the file ---------- */
4769
+ async load() {
4770
+ if (this.loaded) return;
4771
+ this.loaded = true;
4772
+ try {
4773
+ this.file = parseFile(await readFile8(this.config.onboardingFile, "utf8"));
4774
+ } catch {
4775
+ this.file = null;
4776
+ }
4777
+ }
4778
+ /**
4779
+ * Write the file as it is now. One write at a time: sessions settle side by
4780
+ * side, and two renames of the same temporary file would trip over each other.
4781
+ */
4782
+ save() {
4783
+ const write = this.writes.then(async () => {
4784
+ if (!this.file) return;
4785
+ await mkdir2(this.config.dataDir, { recursive: true });
4786
+ await writeJsonAtomic(this.config.onboardingFile, this.file);
4787
+ });
4788
+ this.writes = write.catch(() => {
4789
+ });
4790
+ return write;
4791
+ }
4792
+ refuse(error) {
4793
+ this.finishError = error;
4794
+ this.deps.onChange();
4795
+ return { error };
4796
+ }
4797
+ };
4798
+ function stripFence(text) {
4799
+ const fenced = /^```(?:json)?\s*\n([\s\S]*?)\n```\s*$/.exec(text.trim());
4800
+ return fenced ? fenced[1] : text.trim();
3659
4801
  }
3660
4802
 
3661
4803
  // src/shellpath.ts
3662
- import { spawn as spawn2 } from "node:child_process";
4804
+ import { spawn as spawn3 } from "node:child_process";
3663
4805
  import { userInfo } from "node:os";
3664
4806
  var START = "__DAILY_FOCUS_PATH_START__";
3665
4807
  var END = "__DAILY_FOCUS_PATH_END__";
3666
4808
  var SHELL_TIMEOUT_MS = 5e3;
3667
4809
  var defaultExec = (file, args, { timeout }) => new Promise((resolve2, reject) => {
3668
- const child = spawn2(file, args, { detached: true, stdio: ["ignore", "pipe", "ignore"] });
4810
+ const child = spawn3(file, args, { detached: true, stdio: ["ignore", "pipe", "ignore"] });
3669
4811
  let output = "";
3670
4812
  let settled = false;
3671
4813
  const finish = (error) => {
@@ -3757,7 +4899,7 @@ async function hydratePath(env = process.env, platform = process.platform, exec
3757
4899
 
3758
4900
  // src/server.ts
3759
4901
  var PUBLIC_DIR = resolve(fileURLToPath(new URL("../public", import.meta.url)));
3760
- var VERSION = packageVersion();
4902
+ var VERSION2 = packageVersion();
3761
4903
  var MIME = {
3762
4904
  ".html": "text/html; charset=utf-8",
3763
4905
  ".js": "text/javascript; charset=utf-8",
@@ -3800,7 +4942,7 @@ async function readBody(req) {
3800
4942
  }
3801
4943
  async function serveStatic(res, urlPath) {
3802
4944
  const relative = normalize(decodeURIComponent(urlPath === "/" ? "/index.html" : urlPath));
3803
- const filePath = join4(PUBLIC_DIR, relative);
4945
+ const filePath = join7(PUBLIC_DIR, relative);
3804
4946
  if (filePath !== PUBLIC_DIR && !filePath.startsWith(PUBLIC_DIR + sep)) {
3805
4947
  res.writeHead(403).end("Forbidden");
3806
4948
  return;
@@ -3838,19 +4980,19 @@ function configure(sources) {
3838
4980
  warnings.push(`${sources.settingsError}. The dashboard is ignoring it; saving on the settings page writes a fresh one.`);
3839
4981
  }
3840
4982
  try {
3841
- return { config: loadConfig(layerEnv(sources)), warnings };
4983
+ return { config: loadConfig(layerSettings(sources)), warnings };
3842
4984
  } catch (error) {
3843
4985
  if (Object.keys(sources.settings).length === 0) throw error;
3844
4986
  warnings.push(
3845
4987
  `\`${sources.settingsPath}\` has a value the dashboard can't use, so it is ignoring the whole file: ${error.message}. Fix it on the settings page.`
3846
4988
  );
3847
- return { config: loadConfig(layerEnv(sources, {})), warnings };
4989
+ return { config: loadConfig(layerSettings(sources, {})), warnings };
3848
4990
  }
3849
4991
  }
3850
4992
  function statusOf(state, config, busy) {
3851
4993
  const last = state.agentRun.last;
3852
4994
  return {
3853
- version: VERSION,
4995
+ version: VERSION2,
3854
4996
  dataDir: config.dataDir,
3855
4997
  profile: config.profile,
3856
4998
  setupNeeded: state.setup.needed,
@@ -3873,9 +5015,10 @@ function statusOf(state, config, busy) {
3873
5015
  }
3874
5016
  async function startServer(env, options = {}) {
3875
5017
  const processEnv = env ?? process.env;
3876
- const dotenv = env ? {} : readDotEnv();
3877
- const readSources = () => envSources(processEnv, dotenv);
5018
+ const readSources = () => settingSources(processEnv, options.fixed);
3878
5019
  const { config, warnings: configWarnings } = configure(readSources());
5020
+ const dotenvWarning = env ? null : oldDotenvWarning();
5021
+ if (dotenvWarning) configWarnings.push(dotenvWarning);
3879
5022
  for (const warning of configWarnings) console.warn(`[daily-focus] ${warning}`);
3880
5023
  const store = new Store(config);
3881
5024
  await store.ensureDataDir();
@@ -3889,28 +5032,86 @@ async function startServer(env, options = {}) {
3889
5032
  let restart = null;
3890
5033
  let restarting = false;
3891
5034
  let settingsWrites = Promise.resolve();
3892
- await stat4(join4(PUBLIC_DIR, "app.js")).catch(() => {
5035
+ await stat4(join7(PUBLIC_DIR, "app.js")).catch(() => {
3893
5036
  console.warn(
3894
5037
  PACKAGED ? "[daily-focus] public/app.js is missing from the package: reinstall it" : "[daily-focus] public/app.js is missing: run `npm run build` (npm start does so on its own)"
3895
5038
  );
3896
5039
  });
3897
5040
  const subscribers = /* @__PURE__ */ new Set();
3898
5041
  let assetVersion = await computeAssetVersion(PUBLIC_DIR);
3899
- const board = new Board(config, () => void broadcast(), options.integrations?.github);
5042
+ let autoAsk = null;
5043
+ const board = new Board(
5044
+ config,
5045
+ () => {
5046
+ void broadcast();
5047
+ autoAsk?.poke();
5048
+ },
5049
+ options.integrations?.github
5050
+ );
3900
5051
  const calendar = new CalendarBoard(config, () => void broadcast());
3901
5052
  const tickets = new TicketBoard(config, () => void broadcast(), options.integrations?.jira);
3902
5053
  const assistant = new Assistant(config, {
3903
5054
  onChange: () => {
3904
5055
  void broadcast();
3905
5056
  restartWhenIdle();
5057
+ autoAsk?.poke();
3906
5058
  }
3907
5059
  });
5060
+ autoAsk = new AutoAsk({
5061
+ config,
5062
+ assistant,
5063
+ rows: async () => board.view(await store.readActions(), /* @__PURE__ */ new Date()).rows,
5064
+ context: async (id) => contextFor(id, await buildState()),
5065
+ // Nothing new starts while saved settings wait on the restart that applies them.
5066
+ canStart: () => restart !== "waiting"
5067
+ });
5068
+ const ahead = autoAsk;
3908
5069
  const agent = new AgentRunner(config, {
3909
5070
  onChange: () => {
3910
5071
  void broadcast();
3911
5072
  restartWhenIdle();
3912
5073
  }
3913
5074
  });
5075
+ const onboarding = new Onboarding(config, {
5076
+ onChange: () => {
5077
+ void broadcast();
5078
+ restartWhenIdle();
5079
+ },
5080
+ // A path already set for either CLI is looked at before PATH.
5081
+ detectClis: () => {
5082
+ const configured = {};
5083
+ if (config.agent.cli && config.agent.binPath !== config.agent.cli) configured[config.agent.cli] = config.agent.binPath;
5084
+ if (config.assistant.agent && config.assistant.binPath !== config.assistant.agent) configured[config.assistant.agent] = config.assistant.binPath;
5085
+ return detectClis(configured);
5086
+ },
5087
+ probeCli: (path) => probeCli(path, null, false),
5088
+ readSources,
5089
+ setup: () => setupState(config),
5090
+ identity: () => detectIdentity(config),
5091
+ boards: async () => {
5092
+ const actions = await store.readActions();
5093
+ const now = /* @__PURE__ */ new Date();
5094
+ const health = (view) => ({
5095
+ enabled: view.enabled,
5096
+ fetchedAt: view.fetchedAt,
5097
+ reason: view.reason?.message ?? null
5098
+ });
5099
+ return { github: health(board.view(actions, now)), jira: health(tickets.view(actions, now)) };
5100
+ },
5101
+ listCalendars: async () => {
5102
+ const facts = await runHelper(config.calendar.appPath, []);
5103
+ const today = /* @__PURE__ */ new Map();
5104
+ for (const event of facts.events) {
5105
+ if (typeof event.calendarId === "string") today.set(event.calendarId, (today.get(event.calendarId) ?? 0) + 1);
5106
+ }
5107
+ return facts.calendars.map(({ id, title, source }) => ({ title, source, eventsToday: today.get(id) ?? 0 }));
5108
+ },
5109
+ checkSettings: (values) => {
5110
+ const applied = applySettingsChange(readSources(), values);
5111
+ return "error" in applied ? applied.error : null;
5112
+ },
5113
+ saveSettings: (values) => saveSettingsValues(values)
5114
+ });
3914
5115
  let codexWarnings = [];
3915
5116
  const codexBins = /* @__PURE__ */ new Set();
3916
5117
  if (config.agent.cli === "codex") codexBins.add(config.agent.binPath);
@@ -3920,12 +5121,34 @@ async function startServer(env, options = {}) {
3920
5121
  for (const warning of codexWarnings) console.warn(`[daily-focus] ${warning}`);
3921
5122
  if (codexWarnings.length > 0) void broadcast();
3922
5123
  });
5124
+ async function startRequestedBrief() {
5125
+ if (!agent.enabled || !await firstBriefPending(config) || !await onboarding.takeBriefRequest()) return;
5126
+ await agent.run("hand").catch((err) => {
5127
+ console.error(`[daily-focus] could not start the first brief: ${err.message}`);
5128
+ });
5129
+ }
5130
+ function saveSettingsValues(values) {
5131
+ const saved = settingsWrites.then(async () => {
5132
+ const sources = readSources();
5133
+ const applied = applySettingsChange(sources, values);
5134
+ if ("error" in applied) return applied.error;
5135
+ const changed = JSON.stringify(Object.entries(applied.settings).sort()) !== JSON.stringify(Object.entries(sources.settings).sort());
5136
+ if (changed) {
5137
+ await saveSettings(sources.settingsPath, applied.settings);
5138
+ requestRestart();
5139
+ }
5140
+ return null;
5141
+ });
5142
+ settingsWrites = saved.catch(() => {
5143
+ });
5144
+ return saved;
5145
+ }
3923
5146
  function requestRestart() {
3924
5147
  restart = options.onRestart ? "waiting" : "manual";
3925
5148
  restartWhenIdle();
3926
5149
  }
3927
5150
  function restartWhenIdle() {
3928
- if (restart !== "waiting" || restarting || agent.busy || assistant.busy) return;
5151
+ if (restart !== "waiting" || restarting || agent.busy || assistant.busy || onboarding.busy) return;
3929
5152
  restarting = true;
3930
5153
  setImmediate(() => options.onRestart?.());
3931
5154
  }
@@ -3937,23 +5160,30 @@ async function startServer(env, options = {}) {
3937
5160
  // A link that can't be read is no reason to withhold the page.
3938
5161
  storeLinkWarning(config).catch(() => null)
3939
5162
  ]);
3940
- const sources = readSources();
3941
- const setup = await setupState(config, {
3942
- profileChosen: Boolean(layerEnv(sources).DAILY_FOCUS_PROFILE?.trim())
3943
- });
5163
+ const setup = await setupState(config);
5164
+ const pulls = board.view(actions, now);
5165
+ const onboardingState = await onboarding.view(setup);
3944
5166
  return {
3945
5167
  ...state,
3946
- board: board.view(actions, now),
5168
+ board: pulls,
3947
5169
  tickets: tickets.view(actions, now),
3948
- assistant: assistant.view(),
5170
+ assistant: { ...assistant.view(), auto: ahead.view(pulls.rows) },
3949
5171
  agentRun: agent.view(now),
3950
5172
  setupWarnings: [...configWarnings, ...codexWarnings, ...linkWarning ? [linkWarning] : []],
3951
5173
  restart,
3952
5174
  setup,
5175
+ onboarding: onboardingState,
3953
5176
  assetVersion,
3954
5177
  storeId
3955
5178
  };
3956
5179
  }
5180
+ function contextFor(id, current) {
5181
+ return {
5182
+ item: current.items.find((item) => item.id === id),
5183
+ pull: current.board.rows.find((row) => row.id === id),
5184
+ ticket: current.tickets.rows.find((row) => row.id === id) ?? current.tickets.inProgress.find((row) => row.id === id)
5185
+ };
5186
+ }
3957
5187
  async function broadcast(prebuilt) {
3958
5188
  if (subscribers.size === 0) return;
3959
5189
  let payload;
@@ -3988,7 +5218,13 @@ data: ${payload}
3988
5218
  basename(tempPathFor(config.timeZoneFile)),
3989
5219
  // Appended by this process, which broadcasts on its own.
3990
5220
  basename(config.assistantLogFile),
3991
- basename(config.agentLogFile)
5221
+ basename(config.agentLogFile),
5222
+ // Rewritten by the runner before every run; nothing on the page shows it.
5223
+ basename(config.agentSettingsFile),
5224
+ basename(tempPathFor(config.agentSettingsFile)),
5225
+ // Rewritten by the setup screen, which broadcasts on its own.
5226
+ basename(config.onboardingFile),
5227
+ basename(tempPathFor(config.onboardingFile))
3992
5228
  ]
3993
5229
  }
3994
5230
  );
@@ -4001,6 +5237,8 @@ data: ${payload}
4001
5237
  });
4002
5238
  await assistant.start();
4003
5239
  await agent.start();
5240
+ await onboarding.start(await firstBriefPending(config));
5241
+ await startRequestedBrief();
4004
5242
  let ticks = Promise.resolve();
4005
5243
  function clockTick() {
4006
5244
  ticks = ticks.then(tickOnce).catch((err) => {
@@ -4026,6 +5264,7 @@ data: ${payload}
4026
5264
  void board.start().catch((err) => {
4027
5265
  console.error(`[daily-focus] pull request board failed to start: ${err.message}`);
4028
5266
  });
5267
+ ahead.poke();
4029
5268
  void calendar.start().catch((err) => {
4030
5269
  console.error(`[daily-focus] live agenda failed to start: ${err.message}`);
4031
5270
  });
@@ -4182,12 +5421,7 @@ data: ${JSON.stringify(await buildState())}
4182
5421
  sendJSON(res, 409, { error: "the assistant is off; set DAILY_FOCUS_ASSISTANT to claude or codex" });
4183
5422
  return;
4184
5423
  }
4185
- const current = await buildState();
4186
- const context = {
4187
- item: current.items.find((item) => item.id === id),
4188
- pull: current.board.rows.find((row) => row.id === id),
4189
- ticket: current.tickets.rows.find((row) => row.id === id) ?? current.tickets.inProgress.find((row) => row.id === id)
4190
- };
5424
+ const context = contextFor(id, await buildState());
4191
5425
  if (!context.item && !context.pull && !context.ticket) {
4192
5426
  sendJSON(res, 404, { error: `no item with id ${id}` });
4193
5427
  return;
@@ -4303,6 +5537,40 @@ data: ${JSON.stringify(await buildState())}
4303
5537
  void broadcast(state);
4304
5538
  return;
4305
5539
  }
5540
+ if (path.startsWith("/api/onboarding/") && req.method === "POST") {
5541
+ const body = await readJsonObject(req, res);
5542
+ if (!body) return;
5543
+ const step = typeof body.step === "string" ? body.step : "";
5544
+ let refused2 = null;
5545
+ if (path === "/api/onboarding/answer") {
5546
+ refused2 = await onboarding.answer(step, body.answer);
5547
+ } else if (path === "/api/onboarding/revise") {
5548
+ refused2 = typeof body.text === "string" && body.text.trim() !== "" ? await onboarding.revise(step, body.text, body.draft) : '"text" is required: what to change';
5549
+ } else if (path === "/api/onboarding/retry") {
5550
+ refused2 = await onboarding.retry(step);
5551
+ } else if (path === "/api/onboarding/detect") {
5552
+ await onboarding.detect();
5553
+ } else if (path === "/api/onboarding/finish") {
5554
+ const finished = await onboarding.finish();
5555
+ if ("error" in finished) refused2 = finished.error;
5556
+ else if (!finished.restart) await startRequestedBrief();
5557
+ } else {
5558
+ sendJSON(res, 404, { error: `no such setup action: ${path}` });
5559
+ return;
5560
+ }
5561
+ if (refused2) {
5562
+ sendJSON(res, 409, { error: refused2 });
5563
+ return;
5564
+ }
5565
+ const state = await buildState();
5566
+ sendState(res, state);
5567
+ void broadcast(state);
5568
+ return;
5569
+ }
5570
+ if (path === "/api/onboarding/sources" && req.method === "GET") {
5571
+ sendJSON(res, 200, await onboarding.previewSources());
5572
+ return;
5573
+ }
4306
5574
  if (path === "/api/settings" && req.method === "GET") {
4307
5575
  sendJSON(res, 200, { ...describeSettings(readSources()), restart });
4308
5576
  return;
@@ -4315,20 +5583,7 @@ data: ${JSON.stringify(await buildState())}
4315
5583
  sendJSON(res, 400, { error: '"values" must be an object of settings' });
4316
5584
  return;
4317
5585
  }
4318
- const saved = settingsWrites.then(async () => {
4319
- const sources = readSources();
4320
- const applied = applySettingsChange(sources, values);
4321
- if ("error" in applied) return applied.error;
4322
- const changed = JSON.stringify(Object.entries(applied.settings).sort()) !== JSON.stringify(Object.entries(sources.settings).sort());
4323
- if (changed) {
4324
- await saveSettings(sources.settingsPath, applied.settings);
4325
- requestRestart();
4326
- }
4327
- return null;
4328
- });
4329
- settingsWrites = saved.catch(() => {
4330
- });
4331
- const refused2 = await saved;
5586
+ const refused2 = await saveSettingsValues(values);
4332
5587
  if (refused2) {
4333
5588
  sendJSON(res, 400, { error: refused2 });
4334
5589
  return;
@@ -4368,8 +5623,8 @@ data: ${JSON.stringify(await buildState())}
4368
5623
  if (path === "/api/focus/objective" && req.method === "POST") {
4369
5624
  const body = await readJsonObject(req, res);
4370
5625
  if (!body) return;
4371
- const { objective, blocker } = body;
4372
- for (const [name, value] of [["objective", objective], ["blocker", blocker]]) {
5626
+ const { objective: objective2, blocker } = body;
5627
+ for (const [name, value] of [["objective", objective2], ["blocker", blocker]]) {
4373
5628
  if (value !== void 0 && value !== null && typeof value !== "string") {
4374
5629
  sendJSON(res, 400, { error: `"${name}" must be text, or null to clear it` });
4375
5630
  return;
@@ -4378,7 +5633,7 @@ data: ${JSON.stringify(await buildState())}
4378
5633
  const current = await readEditable(config.focusFile);
4379
5634
  const next = setFocusFields(
4380
5635
  current.text,
4381
- { objective: objective ?? null, blocker: blocker ?? null },
5636
+ { objective: objective2 ?? null, blocker: blocker ?? null },
4382
5637
  FOCUS_TEMPLATE
4383
5638
  );
4384
5639
  const result = await saveEditable(config.focusFile, next, current.version);
@@ -4395,10 +5650,10 @@ data: ${JSON.stringify(await buildState())}
4395
5650
  try {
4396
5651
  const facts = await runHelper(config.calendar.appPath, []);
4397
5652
  const seen = /* @__PURE__ */ new Set();
4398
- const calendars = facts.calendars.filter((entry) => !seen.has(`${entry.title}
5653
+ const calendars2 = facts.calendars.filter((entry) => !seen.has(`${entry.title}
4399
5654
  ${entry.source}`) && seen.add(`${entry.title}
4400
5655
  ${entry.source}`)).map(({ title, source }) => ({ title, source })).sort((a, b) => a.title.localeCompare(b.title));
4401
- sendJSON(res, 200, { calendars });
5656
+ sendJSON(res, 200, { calendars: calendars2 });
4402
5657
  } catch (err) {
4403
5658
  sendJSON(res, 409, { error: err.message });
4404
5659
  }
@@ -4431,11 +5686,13 @@ ${entry.source}`)).map(({ title, source }) => ({ title, source })).sort((a, b) =
4431
5686
  async function stopEverything() {
4432
5687
  clearInterval(heartbeat);
4433
5688
  clearInterval(agentClock);
5689
+ ahead.close();
4434
5690
  board.stop();
4435
5691
  calendar.stop();
4436
5692
  tickets.stop();
4437
5693
  await assistant.close();
4438
5694
  await agent.close();
5695
+ await onboarding.close();
4439
5696
  stopWatching();
4440
5697
  stopWatchingAssets();
4441
5698
  for (const res of subscribers) res.end();
@@ -4487,7 +5744,7 @@ async function runServer(options = {}) {
4487
5744
  let current = null;
4488
5745
  let stopping = false;
4489
5746
  async function start() {
4490
- current = await startServer(options.env, { onRestart: () => void restart(), integrations: options.integrations });
5747
+ current = await startServer(options.env, { onRestart: () => void restart(), integrations: options.integrations, fixed: options.fixed });
4491
5748
  }
4492
5749
  async function restart() {
4493
5750
  console.log("[daily-focus] restarting to apply new settings");