daily-focus 1.2.0-dev.10 → 1.2.0-dev.12

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.
@@ -3,10 +3,13 @@ import {
3
3
  Assistant,
4
4
  AutoAsk,
5
5
  CodexGate,
6
+ codexLatestModel,
6
7
  codexVersionWarning,
8
+ computerUseOff,
7
9
  countBoard,
10
+ refusalFor,
8
11
  resolveBoard
9
- } from "./chunk-ODJGQCM3.js";
12
+ } from "./chunk-FPBY3TAW.js";
10
13
  import {
11
14
  FOCUS_TEMPLATE,
12
15
  sourcesTemplate
@@ -14,7 +17,7 @@ import {
14
17
  import {
15
18
  TimeZoneKeeper,
16
19
  validZone
17
- } from "./chunk-Z4SFMMRQ.js";
20
+ } from "./chunk-HEP46XRX.js";
18
21
  import {
19
22
  describeLink,
20
23
  linkStore,
@@ -27,7 +30,7 @@ import {
27
30
  foldActionLog,
28
31
  tempPathFor,
29
32
  writeJsonAtomic
30
- } from "./chunk-RZNQJ6UG.js";
33
+ } from "./chunk-D3JAWEF7.js";
31
34
  import {
32
35
  CLI_NAMES,
33
36
  LAUNCH_SETTINGS,
@@ -35,6 +38,7 @@ import {
35
38
  briefWritingTools,
36
39
  describeTime,
37
40
  envSources,
41
+ expandHome,
38
42
  laterISO,
39
43
  layerEnv,
40
44
  loadConfig,
@@ -42,7 +46,7 @@ import {
42
46
  readDotEnv,
43
47
  resolveSchedule,
44
48
  scheduledRunDue
45
- } from "./chunk-J7ZYVUG6.js";
49
+ } from "./chunk-DPMHC72S.js";
46
50
  import {
47
51
  PACKAGED,
48
52
  command,
@@ -54,19 +58,259 @@ import {
54
58
  import { createServer } from "node:http";
55
59
  import { createReadStream } from "node:fs";
56
60
  import { stat as stat4 } from "node:fs/promises";
57
- import { basename, extname, join as join4, normalize, resolve, sep } from "node:path";
61
+ import { basename, extname, join as join6, normalize, resolve, sep } from "node:path";
58
62
  import { fileURLToPath } from "node:url";
59
63
 
64
+ // src/clis.ts
65
+ import { execFile as execFile2 } from "node:child_process";
66
+ import { homedir } from "node:os";
67
+ import { join as join2 } from "node:path";
68
+
69
+ // src/setup.ts
70
+ import { execFile } from "node:child_process";
71
+ import { access, constants, readdir, readFile, stat } from "node:fs/promises";
72
+ import { delimiter, isAbsolute, join } from "node:path";
73
+ import { promisify } from "node:util";
74
+ var run = promisify(execFile);
75
+ var PLACEHOLDERS = [
76
+ "Your Name",
77
+ "you@example.com",
78
+ "you@personal.example",
79
+ "your-github-login",
80
+ "Some Shared Calendar",
81
+ "Some Noisy Calendar",
82
+ "<where your colleagues are>",
83
+ "<where you are>",
84
+ "<where you live>",
85
+ "<document id>"
86
+ ];
87
+ function placeholdersIn(text) {
88
+ return PLACEHOLDERS.filter((placeholder) => text.includes(placeholder)).length;
89
+ }
90
+ async function findExecutable(name, path = process.env.PATH ?? "") {
91
+ const candidates = isAbsolute(name) ? [name] : path.split(delimiter).filter(Boolean).map((dir) => join(dir, name));
92
+ for (const candidate of candidates) {
93
+ try {
94
+ await access(candidate, constants.X_OK);
95
+ if ((await stat(candidate)).isFile()) return candidate;
96
+ } catch {
97
+ }
98
+ }
99
+ return null;
100
+ }
101
+ async function findClis(config) {
102
+ const found = {};
103
+ for (const cli2 of CLI_NAMES) {
104
+ const candidates = [
105
+ config.agent.cli === cli2 ? config.agent.binPath : null,
106
+ config.assistant.agent === cli2 ? config.assistant.binPath : null,
107
+ cli2
108
+ ].filter((candidate) => candidate !== null);
109
+ found[cli2] = null;
110
+ for (const candidate of candidates) {
111
+ found[cli2] = await findExecutable(candidate);
112
+ if (found[cli2]) break;
113
+ }
114
+ }
115
+ return found;
116
+ }
117
+ async function hasArchive(config) {
118
+ try {
119
+ return (await readdir(config.archiveDir)).some((name) => name.endsWith(".json"));
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+ async function exists(path) {
125
+ try {
126
+ await stat(path);
127
+ return true;
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+ async function firstBriefPending(config) {
133
+ return !await exists(config.itemsFile) && !await hasArchive(config);
134
+ }
135
+ async function setupState(config) {
136
+ let sources = null;
137
+ try {
138
+ sources = await readFile(config.sourcesFile, "utf8");
139
+ } catch {
140
+ sources = null;
141
+ }
142
+ const supported = process.platform === "darwin";
143
+ return {
144
+ // The file, not whether it parsed: a brief that is there but broken is a
145
+ // problem for its banner to report, not a store to set up from scratch. The
146
+ // agent's clock asks the same question.
147
+ needed: await firstBriefPending(config),
148
+ profile: config.profile,
149
+ sources: { exists: sources !== null, placeholders: sources === null ? 0 : placeholdersIn(sources) },
150
+ clis: await findClis(config),
151
+ calendar: {
152
+ supported,
153
+ built: supported && await exists(config.calendar.appPath),
154
+ chosen: config.calendar.names.length,
155
+ buildCommand: command("build-calendar")
156
+ }
157
+ };
158
+ }
159
+ var identity = null;
160
+ function ghLogins(json) {
161
+ try {
162
+ const hosts = JSON.parse(json).hosts ?? {};
163
+ const accounts = Array.isArray(hosts["github.com"]) ? hosts["github.com"] : [];
164
+ 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);
165
+ } catch {
166
+ return [];
167
+ }
168
+ }
169
+ function detectIdentity(config) {
170
+ identity ??= (async () => {
171
+ const [user, logins, email] = await Promise.all([
172
+ run(config.github.ghPath, ["api", "user", "--jq", '[.login, .name // ""] | join("\\n")'], { timeout: 5e3 }).then(
173
+ ({ stdout }) => stdout.split("\n"),
174
+ () => []
175
+ ),
176
+ run(config.github.ghPath, ["auth", "status", "--json", "hosts"], { timeout: 5e3 }).then(
177
+ ({ stdout }) => ghLogins(stdout),
178
+ () => []
179
+ ),
180
+ run("git", ["config", "--global", "user.email"], { timeout: 2e3 }).then(
181
+ ({ stdout }) => stdout.trim() || null,
182
+ () => null
183
+ )
184
+ ]);
185
+ const login = config.github.accounts[0] ?? (user[0]?.trim() || null);
186
+ return {
187
+ login,
188
+ logins: logins.length ? logins : login ? [login] : [],
189
+ name: user[1]?.trim() || null,
190
+ email
191
+ };
192
+ })();
193
+ return identity;
194
+ }
195
+ function fillIdentity(template, who) {
196
+ let text = template;
197
+ if (who.name) text = text.replaceAll("`Your Name`", `\`${who.name}\``);
198
+ if (who.email) text = text.replaceAll("`you@example.com`", `\`${who.email}\``).replaceAll("`you@personal.example`", `\`${who.email}\``);
199
+ if (who.login) text = text.replaceAll("`your-github-login`", `\`${who.login}\``);
200
+ return text;
201
+ }
202
+
203
+ // src/clis.ts
204
+ var CLI_PREFERENCE = ["codex", "claude"];
205
+ var PROBE_TIMEOUT_MS = 1e4;
206
+ var defaultProbe = (file, args) => new Promise((resolve2, reject) => {
207
+ execFile2(file, [...args], { timeout: PROBE_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
208
+ const code = err ? typeof err.code === "number" ? err.code : null : 0;
209
+ if (err && err.code === "ENOENT") return reject(err);
210
+ resolve2({ code, out: `${stdout}
211
+ ${stderr}`.trim() });
212
+ });
213
+ });
214
+ var CLI_LABEL = { claude: "Claude Code", codex: "Codex" };
215
+ function knownLocations(cli2, home = homedir()) {
216
+ const common = [join2(home, ".local", "bin", cli2), `/opt/homebrew/bin/${cli2}`, `/usr/local/bin/${cli2}`];
217
+ return cli2 === "codex" ? [join2(home, ".codex", "packages", "standalone", "current", "bin", "codex"), ...common] : [...common, join2(home, ".claude", "local", "claude")];
218
+ }
219
+ var VERSION = /(\d+\.\d+\.\d+)/;
220
+ function identify(output) {
221
+ if (/claude code/i.test(output)) return "claude";
222
+ if (/\bcodex(-cli)?\b/i.test(output)) return "codex";
223
+ return null;
224
+ }
225
+ function loggedIn(cli2, result) {
226
+ if (cli2 === "claude") {
227
+ const json = /\{[\s\S]*\}/.exec(result.out)?.[0];
228
+ try {
229
+ const status = json ? JSON.parse(json) : null;
230
+ return typeof status?.loggedIn === "boolean" ? status.loggedIn : null;
231
+ } catch {
232
+ return null;
233
+ }
234
+ }
235
+ if (/not logged in|logged out/i.test(result.out)) return false;
236
+ if (/logged in/i.test(result.out)) return true;
237
+ return result.code !== null && result.code !== 0 ? false : null;
238
+ }
239
+ var LOGIN = {
240
+ claude: { args: ["auth", "status"], fix: "claude auth login" },
241
+ codex: { args: ["login", "status"], fix: "codex login" }
242
+ };
243
+ async function probeCli(path, expected, onPath, probe = defaultProbe) {
244
+ const missing = (problem) => ({
245
+ cli: expected,
246
+ found: { path, version: null, onPath, ready: false, problem, fix: null }
247
+ });
248
+ let version;
249
+ try {
250
+ version = await probe(path, ["--version"]);
251
+ } catch {
252
+ return missing(`Nothing runs at ${path}`);
253
+ }
254
+ const cli2 = identify(version.out) ?? expected;
255
+ if (version.code !== 0 || !cli2) {
256
+ const said = version.out.split("\n")[0]?.slice(0, 120) || "nothing";
257
+ return missing(cli2 ? `${path} didn't say its version` : `${path} isn't Codex or Claude Code: it says \u201C${said}\u201D`);
258
+ }
259
+ const number = VERSION.exec(version.out)?.[1] ?? null;
260
+ const tooOld = cli2 === "codex" ? refusalFor(number) : null;
261
+ if (tooOld) {
262
+ return { cli: cli2, found: { path, version: number, onPath, ready: false, problem: tooOld, fix: "codex update" } };
263
+ }
264
+ let login = null;
265
+ try {
266
+ login = loggedIn(cli2, await probe(path, LOGIN[cli2].args));
267
+ } catch {
268
+ login = null;
269
+ }
270
+ return {
271
+ cli: cli2,
272
+ 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 }
273
+ };
274
+ }
275
+ async function detectClis(configured = {}, opts = {}) {
276
+ const report = {};
277
+ await Promise.all(
278
+ CLI_NAMES.map(async (cli2) => {
279
+ const onPath = await findExecutable(cli2, opts.path);
280
+ const candidates = [
281
+ ...configured[cli2] ? [await findExecutable(configured[cli2], opts.path)] : [],
282
+ onPath,
283
+ ...await Promise.all((opts.locations ?? knownLocations)(cli2).map((location) => findExecutable(location)))
284
+ ];
285
+ const seen = /* @__PURE__ */ new Set();
286
+ for (const candidate of candidates) {
287
+ if (!candidate || seen.has(candidate)) continue;
288
+ seen.add(candidate);
289
+ const { found } = await probeCli(candidate, cli2, candidate === onPath, opts.probe);
290
+ if (found.version !== null || found.ready) {
291
+ report[cli2] = found;
292
+ return;
293
+ }
294
+ }
295
+ report[cli2] = { path: null, version: null, onPath: false, ready: false, problem: `${CLI_LABEL[cli2]} isn't installed`, fix: null };
296
+ })
297
+ );
298
+ return report;
299
+ }
300
+ function preferredCli(report) {
301
+ return CLI_PREFERENCE.find((cli2) => report[cli2].ready) ?? null;
302
+ }
303
+
60
304
  // src/editable.ts
61
305
  import { createHash } from "node:crypto";
62
- import { readFile, rename, writeFile } from "node:fs/promises";
306
+ import { readFile as readFile2, rename, writeFile } from "node:fs/promises";
63
307
  var ABSENT = "absent";
64
308
  function versionOf(text) {
65
309
  return text === null ? ABSENT : createHash("sha256").update(text).digest("hex").slice(0, 16);
66
310
  }
67
311
  async function readEditable(path) {
68
312
  try {
69
- const text = await readFile(path, "utf8");
313
+ const text = await readFile2(path, "utf8");
70
314
  return { text, version: versionOf(text) };
71
315
  } catch (error) {
72
316
  if (error.code === "ENOENT") return { text: null, version: ABSENT };
@@ -127,7 +371,7 @@ ${lines.join("\n")}
127
371
  }
128
372
 
129
373
  // src/board.ts
130
- import { readFile as readFile2 } from "node:fs/promises";
374
+ import { readFile as readFile3 } from "node:fs/promises";
131
375
 
132
376
  // src/failure.ts
133
377
  var MAX_DETAIL = 4e3;
@@ -221,9 +465,9 @@ function reasonFor(code) {
221
465
  }
222
466
 
223
467
  // src/github.ts
224
- import { execFile } from "node:child_process";
225
- import { promisify } from "node:util";
226
- var run = promisify(execFile);
468
+ import { execFile as execFile3 } from "node:child_process";
469
+ import { promisify as promisify2 } from "node:util";
470
+ var run2 = promisify2(execFile3);
227
471
  var GH_TIMEOUT_MS = 15e3;
228
472
  var FETCH_TIMEOUT_MS = 3e4;
229
473
  var GRAPHQL_URL = "https://api.github.com/graphql";
@@ -246,7 +490,7 @@ var GhAuthError = class extends Error {
246
490
  };
247
491
  async function gh(ghPath, args) {
248
492
  try {
249
- const { stdout, stderr } = await run(ghPath, args, { env: GH_ENV, timeout: GH_TIMEOUT_MS, maxBuffer: 1 << 20 });
493
+ const { stdout, stderr } = await run2(ghPath, args, { env: GH_ENV, timeout: GH_TIMEOUT_MS, maxBuffer: 1 << 20 });
250
494
  return { stdout, stderr, code: 0 };
251
495
  } catch (err) {
252
496
  const failure = err;
@@ -1178,7 +1422,7 @@ ${details[i] || "(nothing more)"}`).join("\n\n");
1178
1422
  let lowestRemaining = null;
1179
1423
  const results = await Promise.allSettled(
1180
1424
  identities.map(
1181
- (identity2) => this.#deps.fetch(identity2.token, this.#config.github.scope, identity2.account.login, this.#abort.signal)
1425
+ (identity3) => this.#deps.fetch(identity3.token, this.#config.github.scope, identity3.account.login, this.#abort.signal)
1182
1426
  )
1183
1427
  );
1184
1428
  results.forEach((result, i) => {
@@ -1323,7 +1567,7 @@ function normalizeStoredPull(raw) {
1323
1567
  async function readPullsFile(path) {
1324
1568
  let text;
1325
1569
  try {
1326
- text = await readFile2(path, "utf8");
1570
+ text = await readFile3(path, "utf8");
1327
1571
  } catch {
1328
1572
  return null;
1329
1573
  }
@@ -1347,12 +1591,12 @@ async function readPullsFile(path) {
1347
1591
  }
1348
1592
 
1349
1593
  // src/calendar.ts
1350
- import { execFile as execFile2 } from "node:child_process";
1351
- import { readFile as readFile3, rm, stat } from "node:fs/promises";
1594
+ import { execFile as execFile4 } from "node:child_process";
1595
+ import { readFile as readFile4, rm, stat as stat2 } from "node:fs/promises";
1352
1596
  import { tmpdir } from "node:os";
1353
- import { join } from "node:path";
1354
- import { promisify as promisify2 } from "node:util";
1355
- var run2 = promisify2(execFile2);
1597
+ import { join as join3 } from "node:path";
1598
+ import { promisify as promisify3 } from "node:util";
1599
+ var run3 = promisify3(execFile4);
1356
1600
  var HELPER_TIMEOUT_MS = 2e4;
1357
1601
  var POLL_MS = 150;
1358
1602
  var DECLINED = 3;
@@ -1375,17 +1619,17 @@ function parseCalendarFacts(raw) {
1375
1619
  if (typeof raw !== "object" || raw === null) return null;
1376
1620
  const obj = raw;
1377
1621
  if (str2(obj.error)) return null;
1378
- const calendars = [];
1622
+ const calendars2 = [];
1379
1623
  if (Array.isArray(obj.calendars)) {
1380
1624
  for (const entry of obj.calendars) {
1381
1625
  const id = str2(entry?.id);
1382
1626
  const title = str2(entry?.title);
1383
- if (id && title) calendars.push({ id, title, source: str2(entry?.source) ?? "" });
1627
+ if (id && title) calendars2.push({ id, title, source: str2(entry?.source) ?? "" });
1384
1628
  }
1385
1629
  }
1386
1630
  return {
1387
1631
  generatedAt: str2(obj.generatedAt) ?? (/* @__PURE__ */ new Date(0)).toISOString(),
1388
- calendars,
1632
+ calendars: calendars2,
1389
1633
  events: Array.isArray(obj.events) ? obj.events : []
1390
1634
  };
1391
1635
  }
@@ -1442,17 +1686,17 @@ var CalendarHelperError = class extends Error {
1442
1686
  };
1443
1687
  async function runHelper(appPath, selfAddresses) {
1444
1688
  try {
1445
- await stat(appPath);
1689
+ await stat2(appPath);
1446
1690
  } catch {
1447
1691
  throw new CalendarHelperError(
1448
1692
  `the calendar helper isn't built at ${appPath} \u2014 run ${command("build-calendar")}, or point DAILY_FOCUS_CALENDAR_APP at an existing build`
1449
1693
  );
1450
1694
  }
1451
- const out = join(tmpdir(), `daily-focus-calendar-${process.pid}-${Date.now()}.json`);
1695
+ const out = join3(tmpdir(), `daily-focus-calendar-${process.pid}-${Date.now()}.json`);
1452
1696
  try {
1453
1697
  try {
1454
1698
  const zone = process.env.TZ ? ["--env", `TZ=${process.env.TZ}`] : [];
1455
- await run2("/usr/bin/open", ["-n", "-a", appPath, ...zone, "--args", out, selfAddresses.join(",")], {
1699
+ await run3("/usr/bin/open", ["-n", "-a", appPath, ...zone, "--args", out, selfAddresses.join(",")], {
1456
1700
  timeout: HELPER_TIMEOUT_MS
1457
1701
  });
1458
1702
  } catch (err) {
@@ -1463,7 +1707,7 @@ async function runHelper(appPath, selfAddresses) {
1463
1707
  for (; ; ) {
1464
1708
  let text = null;
1465
1709
  try {
1466
- text = await readFile3(out, "utf8");
1710
+ text = await readFile4(out, "utf8");
1467
1711
  } catch {
1468
1712
  text = null;
1469
1713
  }
@@ -1487,7 +1731,7 @@ async function runHelper(appPath, selfAddresses) {
1487
1731
  }
1488
1732
 
1489
1733
  // src/calendarboard.ts
1490
- import { readFile as readFile4 } from "node:fs/promises";
1734
+ import { readFile as readFile5 } from "node:fs/promises";
1491
1735
  var MAX_BACKOFF_MS2 = 15 * 6e4;
1492
1736
  var realDeps2 = { run: runHelper };
1493
1737
  var CalendarBoard = class {
@@ -1639,18 +1883,18 @@ var CalendarBoard = class {
1639
1883
  };
1640
1884
  async function readCalendarFile(path) {
1641
1885
  try {
1642
- return parseCalendarFacts(JSON.parse(await readFile4(path, "utf8")));
1886
+ return parseCalendarFacts(JSON.parse(await readFile5(path, "utf8")));
1643
1887
  } catch {
1644
1888
  return null;
1645
1889
  }
1646
1890
  }
1647
1891
 
1648
1892
  // src/ticketboard.ts
1649
- import { readFile as readFile5 } from "node:fs/promises";
1893
+ import { readFile as readFile6 } from "node:fs/promises";
1650
1894
 
1651
1895
  // src/jira.ts
1652
- import { execFile as execFile3 } from "node:child_process";
1653
- import { promisify as promisify3 } from "node:util";
1896
+ import { execFile as execFile5 } from "node:child_process";
1897
+ import { promisify as promisify4 } from "node:util";
1654
1898
 
1655
1899
  // src/linked.ts
1656
1900
  var CLOSED_LOOKBACK_DAYS = 60;
@@ -1732,7 +1976,7 @@ function reconcile(ticket, linked, jiraCount) {
1732
1976
  }
1733
1977
 
1734
1978
  // src/jira.ts
1735
- var run3 = promisify3(execFile3);
1979
+ var run4 = promisify4(execFile5);
1736
1980
  var ACLI_TIMEOUT_MS = 45e3;
1737
1981
  var PAGE_LIMIT = 200;
1738
1982
  var ACLI_ENV = { ...process.env, NO_COLOR: "1" };
@@ -1768,7 +2012,7 @@ var JiraTransitionError = class extends Error {
1768
2012
  };
1769
2013
  async function acli(acliPath, args) {
1770
2014
  try {
1771
- const { stdout, stderr } = await run3(acliPath, args, {
2015
+ const { stdout, stderr } = await run4(acliPath, args, {
1772
2016
  env: ACLI_ENV,
1773
2017
  timeout: ACLI_TIMEOUT_MS,
1774
2018
  // A hundred tickets of raw Jira issue JSON measured 135 KB; this is room
@@ -1914,13 +2158,13 @@ function jsonObjectAt(text, from) {
1914
2158
  }
1915
2159
  return null;
1916
2160
  }
1917
- function readDevPullRequests(field) {
1918
- if (typeof field !== "string") return null;
1919
- const marker = field.indexOf("json=");
2161
+ function readDevPullRequests(field2) {
2162
+ if (typeof field2 !== "string") return null;
2163
+ const marker = field2.indexOf("json=");
1920
2164
  if (marker === -1) return null;
1921
- const opens = field.indexOf("{", marker);
2165
+ const opens = field2.indexOf("{", marker);
1922
2166
  if (opens === -1) return null;
1923
- const body = jsonObjectAt(field, opens);
2167
+ const body = jsonObjectAt(field2, opens);
1924
2168
  if (body === null) return null;
1925
2169
  let parsed;
1926
2170
  try {
@@ -2050,19 +2294,19 @@ async function fetchTickets(acliPath, opts) {
2050
2294
  const undecided = new Set(reconciled.filter((ticket) => !ticket.hasOpenPr && !ticket.allPrsClosed).map((ticket) => ticket.key));
2051
2295
  const unansweredNow = unanswered.filter((key) => undecided.has(key));
2052
2296
  const unaccountedNow = unaccounted.filter((key) => undecided.has(key));
2053
- const listed = (keys2) => keys2.map((key) => {
2297
+ const listed2 = (keys2) => keys2.map((key) => {
2054
2298
  const url = browseUrl(opts.site, key);
2055
2299
  return url === null ? key : `[${key}](${url})`;
2056
2300
  }).join(", ");
2057
2301
  const heldOut = (keys2) => `${keys2.length === 1 ? "it is" : "they are"} held out of "no open pull requests left" rather than guessed at`;
2058
2302
  if (unansweredNow.length > 0) {
2059
2303
  warnings.push(
2060
- `Could not read Jira's development panel for ${listed(unansweredNow)}, so ${heldOut(unansweredNow)}. The next read may well manage it.`
2304
+ `Could not read Jira's development panel for ${listed2(unansweredNow)}, so ${heldOut(unansweredNow)}. The next read may well manage it.`
2061
2305
  );
2062
2306
  }
2063
2307
  if (unaccountedNow.length > 0) {
2064
2308
  warnings.push(
2065
- `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.`
2309
+ `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.`
2066
2310
  );
2067
2311
  }
2068
2312
  if (!opts.site) {
@@ -2158,7 +2402,7 @@ function ticketCourtOrder(court) {
2158
2402
  function inStatus(ticket, statuses) {
2159
2403
  const status = ticket.workflowStatus.trim().toLowerCase();
2160
2404
  if (status === "") return false;
2161
- return statuses.some((listed) => listed.trim().toLowerCase() === status);
2405
+ return statuses.some((listed2) => listed2.trim().toLowerCase() === status);
2162
2406
  }
2163
2407
  function judge(ticket, holdStatuses = []) {
2164
2408
  if (ticket.statusCategory === "done") return null;
@@ -2349,15 +2593,15 @@ var TicketBoard = class {
2349
2593
  }
2350
2594
  async #read() {
2351
2595
  const { acliPath, projects, site: configuredSite } = this.#config.jira;
2352
- let identity2;
2596
+ let identity3;
2353
2597
  try {
2354
- identity2 = await this.#deps.identity(acliPath);
2598
+ identity3 = await this.#deps.identity(acliPath);
2355
2599
  } catch (err) {
2356
2600
  this.#reason = { message: `${err.message}, or set DAILY_FOCUS_JIRA=off`, detail: errorDetail(err) };
2357
2601
  this.#streak = extend(this.#streak);
2358
2602
  return;
2359
2603
  }
2360
- const site = configuredSite ?? identity2.site;
2604
+ const site = configuredSite ?? identity3.site;
2361
2605
  const githubWarnings = [];
2362
2606
  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(
2363
2607
  (read) => {
@@ -2382,7 +2626,7 @@ var TicketBoard = class {
2382
2626
  this.#file = {
2383
2627
  version: 1,
2384
2628
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
2385
- account: identity2.account,
2629
+ account: identity3.account,
2386
2630
  projects: [...projects],
2387
2631
  warnings,
2388
2632
  tickets,
@@ -2451,7 +2695,7 @@ function storedPulls(value) {
2451
2695
  async function readTicketsFile(path) {
2452
2696
  let text;
2453
2697
  try {
2454
- text = await readFile5(path, "utf8");
2698
+ text = await readFile6(path, "utf8");
2455
2699
  } catch {
2456
2700
  return null;
2457
2701
  }
@@ -2484,8 +2728,8 @@ async function readTicketsFile(path) {
2484
2728
  // src/agent.ts
2485
2729
  import { spawn } from "node:child_process";
2486
2730
  import { randomUUID } from "node:crypto";
2487
- import { appendFile, mkdir, readFile as readFile6 } from "node:fs/promises";
2488
- import { homedir } from "node:os";
2731
+ import { appendFile, mkdir, readFile as readFile7 } from "node:fs/promises";
2732
+ import { homedir as homedir2 } from "node:os";
2489
2733
  import { createInterface } from "node:readline";
2490
2734
  var RUN_TIMEOUT_MS = 60 * 6e4;
2491
2735
  var TURN_TIMEOUT_MS = 10 * 6e4;
@@ -2505,6 +2749,11 @@ function agentPrompt(promptPath) {
2505
2749
  "Finish by reporting back as that file asks you to."
2506
2750
  ].join("\n");
2507
2751
  }
2752
+ function agentSettings(config) {
2753
+ return {
2754
+ searchTrash: config.agent.searchTrash
2755
+ };
2756
+ }
2508
2757
  function followUpPrompt(question) {
2509
2758
  return [
2510
2759
  "A follow-up question about the brief you wrote in this session. Answer it here.",
@@ -2515,7 +2764,7 @@ function followUpPrompt(question) {
2515
2764
  question.trim()
2516
2765
  ].join("\n");
2517
2766
  }
2518
- function displayPath(path, home = homedir()) {
2767
+ function displayPath(path, home = homedir2()) {
2519
2768
  return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
2520
2769
  }
2521
2770
  function foldAgentLog(text) {
@@ -2622,7 +2871,7 @@ var AgentRunner = class {
2622
2871
  this.loaded = true;
2623
2872
  let text = "";
2624
2873
  try {
2625
- text = await readFile6(this.config.agentLogFile, "utf8");
2874
+ text = await readFile7(this.config.agentLogFile, "utf8");
2626
2875
  } catch {
2627
2876
  return;
2628
2877
  }
@@ -2700,8 +2949,8 @@ var AgentRunner = class {
2700
2949
  * decided on.
2701
2950
  */
2702
2951
  async run(trigger = "hand", now = /* @__PURE__ */ new Date()) {
2703
- const cli = this.config.agent.cli;
2704
- if (!cli) throw new Error("running the morning agent is off; set DAILY_FOCUS_AGENT to codex or claude");
2952
+ const cli2 = this.config.agent.cli;
2953
+ if (!cli2) throw new Error("running the morning agent is off; set DAILY_FOCUS_AGENT to codex or claude");
2705
2954
  if (this.running) {
2706
2955
  throw new Error(
2707
2956
  this.running.turn ? "the morning agent is still answering a question" : "the morning agent is already running"
@@ -2710,7 +2959,7 @@ var AgentRunner = class {
2710
2959
  await this.start();
2711
2960
  const run5 = {
2712
2961
  id: randomUUID(),
2713
- cli,
2962
+ cli: cli2,
2714
2963
  trigger,
2715
2964
  sessionId: null,
2716
2965
  startedAt: now.toISOString(),
@@ -2723,8 +2972,14 @@ var AgentRunner = class {
2723
2972
  resumable: false
2724
2973
  };
2725
2974
  this.runs.push(run5);
2726
- await this.append({ event: "started", run: run5.id, cli, trigger, at: run5.startedAt });
2727
- const adapter = ADAPTERS[cli];
2975
+ await this.append({ event: "started", run: run5.id, cli: cli2, trigger, at: run5.startedAt });
2976
+ try {
2977
+ await writeJsonAtomic(this.config.agentSettingsFile, agentSettings(this.config));
2978
+ } catch (err) {
2979
+ await this.finish(run5, null, { ok: false, error: `could not write ${displayPath(this.config.agentSettingsFile)}: ${err.message}` });
2980
+ return run5;
2981
+ }
2982
+ const adapter = ADAPTERS[cli2];
2728
2983
  const args = adapter.args({
2729
2984
  prompt: agentPrompt(displayPath(this.config.promptFile)),
2730
2985
  sessionId: null,
@@ -2743,8 +2998,8 @@ var AgentRunner = class {
2743
2998
  * is running; the answer arrives through `onChange` and the next state.
2744
2999
  */
2745
3000
  async ask(runId, question) {
2746
- const cli = this.config.agent.cli;
2747
- if (!cli) throw new Error("running the morning agent is off; set DAILY_FOCUS_AGENT to codex or claude");
3001
+ const cli2 = this.config.agent.cli;
3002
+ if (!cli2) throw new Error("running the morning agent is off; set DAILY_FOCUS_AGENT to codex or claude");
2748
3003
  await this.start();
2749
3004
  const run5 = this.runs.find((candidate) => candidate.id === runId);
2750
3005
  if (!run5) throw new Error("no such run");
@@ -2755,7 +3010,7 @@ var AgentRunner = class {
2755
3010
  }
2756
3011
  if (!this.resumable(run5)) {
2757
3012
  throw new Error(
2758
- run5.sessionId === null ? "this run left no session to continue" : `this run was made by ${run5.cli}, and the agent is ${cli} now`
3013
+ run5.sessionId === null ? "this run left no session to continue" : `this run was made by ${run5.cli}, and the agent is ${cli2} now`
2759
3014
  );
2760
3015
  }
2761
3016
  const text = question.trim();
@@ -2771,7 +3026,7 @@ var AgentRunner = class {
2771
3026
  };
2772
3027
  run5.turns.push(turn);
2773
3028
  await this.append({ event: "asked", run: run5.id, turn: turn.id, question: text, at: turn.startedAt });
2774
- const adapter = ADAPTERS[cli];
3029
+ const adapter = ADAPTERS[cli2];
2775
3030
  const args = adapter.args({
2776
3031
  prompt: followUpPrompt(text),
2777
3032
  sessionId: run5.sessionId,
@@ -2792,10 +3047,10 @@ var AgentRunner = class {
2792
3047
  * session under the directory it was started in, and looks for it there.
2793
3048
  */
2794
3049
  async spawnInto(run5, turn, args, timeoutMs, timeoutReason) {
2795
- const cli = run5.cli;
2796
- const adapter = ADAPTERS[cli];
3050
+ const cli2 = run5.cli;
3051
+ const adapter = ADAPTERS[cli2];
2797
3052
  await mkdir(this.config.dataDir, { recursive: true });
2798
- const refusal2 = cli === "codex" ? await this.codexGate.refusal() : null;
3053
+ const refusal2 = cli2 === "codex" ? await this.codexGate.refusal() : null;
2799
3054
  if (refusal2) {
2800
3055
  await this.finish(run5, turn, { ok: false, error: refusal2 });
2801
3056
  return;
@@ -2851,7 +3106,7 @@ ${event.text}` : event.text;
2851
3106
  this.running = null;
2852
3107
  const outcome = running.abortReason ? { ok: false, error: running.abortReason } : done ?? {
2853
3108
  ok: false,
2854
- error: `${cli} exited with code ${code}${stderr.length ? `: ${stderr.join("").trim().split("\n").slice(-3).join(" ")}` : ""}`
3109
+ error: `${cli2} exited with code ${code}${stderr.length ? `: ${stderr.join("").trim().split("\n").slice(-3).join(" ")}` : ""}`
2855
3110
  };
2856
3111
  void this.finish(run5, turn, outcome, running.abortReason !== null);
2857
3112
  });
@@ -2954,23 +3209,23 @@ function watchDataDir(dir, onChange, { debounceMs = 120, ignore = [] } = {}) {
2954
3209
  }
2955
3210
 
2956
3211
  // src/assets.ts
2957
- import { readdir, stat as stat2 } from "node:fs/promises";
2958
- import { join as join2 } from "node:path";
3212
+ import { readdir as readdir2, stat as stat3 } from "node:fs/promises";
3213
+ import { join as join4 } from "node:path";
2959
3214
  async function computeAssetVersion(dir) {
2960
3215
  const parts = [];
2961
3216
  async function walk(current) {
2962
3217
  let entries;
2963
3218
  try {
2964
- entries = await readdir(current, { withFileTypes: true });
3219
+ entries = await readdir2(current, { withFileTypes: true });
2965
3220
  } catch {
2966
3221
  return;
2967
3222
  }
2968
3223
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
2969
- const path = join2(current, entry.name);
3224
+ const path = join4(current, entry.name);
2970
3225
  if (entry.isDirectory()) {
2971
3226
  await walk(path);
2972
3227
  } else {
2973
- const info = await stat2(path);
3228
+ const info = await stat3(path);
2974
3229
  parts.push(`${entry.name}:${info.size}:${info.mtimeMs}`);
2975
3230
  }
2976
3231
  }
@@ -2989,8 +3244,8 @@ var FAVICONS = {
2989
3244
  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"/>`,
2990
3245
  personal: `<path fill="#1baf7a" fill-rule="evenodd" d="M50 8 96 48H84v44H16V48H4ZM41 92V66h18v26Z"/>`
2991
3246
  };
2992
- function faviconSvg(profile) {
2993
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${FAVICONS[profile]}</svg>`;
3247
+ function faviconSvg(profile2) {
3248
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${FAVICONS[profile2]}</svg>`;
2994
3249
  }
2995
3250
 
2996
3251
  // src/guard.ts
@@ -3022,6 +3277,542 @@ function refusal(req, listenHost) {
3022
3277
  return null;
3023
3278
  }
3024
3279
 
3280
+ // src/onboarding.ts
3281
+ import { spawn as spawn2 } from "node:child_process";
3282
+ import { mkdir as mkdir2, mkdtemp, readFile as readFile8, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
3283
+ import { tmpdir as tmpdir2 } from "node:os";
3284
+ import { join as join5 } from "node:path";
3285
+ import { createInterface as createInterface2 } from "node:readline";
3286
+
3287
+ // src/questions.ts
3288
+ var INSTALL = {
3289
+ codex: { label: "Install Codex", url: "https://developers.openai.com/codex/cli" },
3290
+ claude: { label: "Install Claude Code", url: "https://code.claude.com/docs" }
3291
+ };
3292
+ function option(value, label, detail = null, group = null) {
3293
+ return { value, label, detail, group };
3294
+ }
3295
+ function choice(id, options, selected, extra = {}) {
3296
+ return { id, label: null, multiple: false, options, selected, custom: null, ...extra };
3297
+ }
3298
+ function field(id, label, value, extra = {}) {
3299
+ return { id, label, value, placeholder: null, help: null, ...extra };
3300
+ }
3301
+ function card(parts) {
3302
+ return { why: null, choices: [], fields: [], problem: null, ...parts };
3303
+ }
3304
+ function one(answer, id) {
3305
+ return answer.choices[id]?.[0] ?? null;
3306
+ }
3307
+ function str3(value) {
3308
+ return typeof value === "string" ? value.trim() : "";
3309
+ }
3310
+ function proposalOf(ctx, id) {
3311
+ const proposal = ctx.proposals[id];
3312
+ return typeof proposal === "object" && proposal !== null && !Array.isArray(proposal) ? proposal : null;
3313
+ }
3314
+ function listed(names) {
3315
+ if (names.length <= 1) return names.join("");
3316
+ return `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
3317
+ }
3318
+ function ticked(name) {
3319
+ return `\`${name.replaceAll("`", "'")}\``;
3320
+ }
3321
+ function binFor(ctx, cli2) {
3322
+ const found = cli2 ? ctx.clis?.[cli2] : null;
3323
+ return found?.path && !found.onPath ? found.path : null;
3324
+ }
3325
+ var cli = {
3326
+ id: "cli",
3327
+ title: () => "Which agent should write your brief?",
3328
+ topic: "your coding agent",
3329
+ keys: ["DAILY_FOCUS_AGENT"],
3330
+ later: { label: "Decide later", summary: "No agent yet: the boards work, and there is no brief until you choose one." },
3331
+ card(ctx) {
3332
+ if (!ctx.clis) return card({});
3333
+ const report = ctx.clis;
3334
+ const ready = CLI_PREFERENCE.filter((name) => report[name].ready);
3335
+ if (ready.length === 0) {
3336
+ const lines = CLI_PREFERENCE.map((name) => `${report[name].problem ?? `${CLI_LABEL[name]} can't be used`}.`);
3337
+ const fix = CLI_PREFERENCE.map((name) => report[name].fix).find((command3) => command3) ?? null;
3338
+ return card({
3339
+ problem: {
3340
+ message: `${lines.join(" ")} ${fix ? "Run this in a terminal, then check again." : "Install one, log in once in a terminal, then check again."}`,
3341
+ command: fix,
3342
+ blocking: true,
3343
+ links: CLI_PREFERENCE.filter((name) => !report[name].path).map((name) => INSTALL[name])
3344
+ },
3345
+ fields: [
3346
+ field("path", "Installed somewhere setup didn\u2019t look? Its path", "", {
3347
+ placeholder: "/opt/homebrew/bin/codex",
3348
+ help: "The program itself, codex or claude. Setup checks it before using it."
3349
+ })
3350
+ ]
3351
+ });
3352
+ }
3353
+ const set = ctx.current.DAILY_FOCUS_AGENT;
3354
+ const others = CLI_PREFERENCE.filter((name) => !report[name].ready && report[name].path).map((name) => `${report[name].problem}.`);
3355
+ return card({
3356
+ why: [
3357
+ 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}.`,
3358
+ ...others
3359
+ ].join(" "),
3360
+ choices: [
3361
+ choice(
3362
+ "cli",
3363
+ ready.map((name) => option(name, CLI_LABEL[name], [report[name].version, report[name].path].filter(Boolean).join(", at "))),
3364
+ [set && ready.includes(set) ? set : ready[0]]
3365
+ )
3366
+ ]
3367
+ });
3368
+ },
3369
+ summary: (answer) => {
3370
+ const chosen = CLI_LABEL[one(answer, "cli")];
3371
+ return chosen ? `${chosen} writes your brief.` : "No agent writes your brief yet.";
3372
+ },
3373
+ // The assistant comes with it, on the same CLI, unless it has been set: it
3374
+ // only reads and drafts, and it is what the Ask button on every row needs.
3375
+ settings: (answer, ctx) => {
3376
+ const chosen = one(answer, "cli");
3377
+ const bin = binFor(ctx, chosen);
3378
+ const assistant = chosen && !ctx.current.DAILY_FOCUS_ASSISTANT;
3379
+ return {
3380
+ DAILY_FOCUS_AGENT: chosen,
3381
+ ...bin ? { DAILY_FOCUS_AGENT_BIN: bin } : {},
3382
+ ...assistant ? { DAILY_FOCUS_ASSISTANT: chosen, ...bin ? { DAILY_FOCUS_ASSISTANT_BIN: bin } : {} } : {}
3383
+ };
3384
+ }
3385
+ };
3386
+ var profile = {
3387
+ id: "profile",
3388
+ topic: "what it covers",
3389
+ title: () => "What should your brief cover?",
3390
+ keys: ["DAILY_FOCUS_PROFILE"],
3391
+ later: null,
3392
+ card: (ctx) => card({
3393
+ choices: [
3394
+ choice(
3395
+ "profile",
3396
+ [
3397
+ option("work", "Your work", "Work mail and calendars, pull requests, tickets and meeting notes."),
3398
+ option("personal", "Your personal life", "Personal mail, family calendars, reminders and messages.")
3399
+ ],
3400
+ [ctx.current.DAILY_FOCUS_PROFILE === "personal" ? "personal" : "work"]
3401
+ )
3402
+ ],
3403
+ why: "One dashboard briefs one of them. For both, run a second dashboard with its own store."
3404
+ }),
3405
+ summary: (answer) => one(answer, "profile") === "personal" ? "It briefs your personal life." : "It briefs your work.",
3406
+ settings: (answer) => ({ DAILY_FOCUS_PROFILE: one(answer, "profile") })
3407
+ };
3408
+ var TIMES = ["06:00", "06:30", "07:00", "07:30", "08:00"];
3409
+ var DAYS = { "1-5": "Weekdays", "0-6": "Every day" };
3410
+ var schedule = {
3411
+ id: "schedule",
3412
+ topic: "when it runs",
3413
+ title: () => "When should your brief be ready?",
3414
+ applies: (ctx) => ctx.cli !== null,
3415
+ keys: ["DAILY_FOCUS_AGENT_AT", "DAILY_FOCUS_AGENT_DAYS"],
3416
+ later: null,
3417
+ card(ctx) {
3418
+ const at = ctx.current.DAILY_FOCUS_AGENT_AT || "07:00";
3419
+ const days = ctx.current.DAILY_FOCUS_AGENT_DAYS || "1-5";
3420
+ const times = [.../* @__PURE__ */ new Set([...TIMES, ...at === "off" ? [] : [at]])].sort();
3421
+ return card({
3422
+ 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.`,
3423
+ choices: [
3424
+ choice("at", [...times.map((time) => option(time, time)), option("off", "Only when I ask")], [at], {
3425
+ label: "Time",
3426
+ custom: "Another time, such as 05:45"
3427
+ }),
3428
+ choice("days", Object.entries({ ...DAYS, ...DAYS[days] ? {} : { [days]: days } }).map(([value, label]) => option(value, label)), [days], {
3429
+ label: "Days"
3430
+ })
3431
+ ]
3432
+ });
3433
+ },
3434
+ summary(answer) {
3435
+ const at = one(answer, "at");
3436
+ if (!at || at === "off") return "It runs only when you ask.";
3437
+ const days = one(answer, "days") ?? "1-5";
3438
+ return `It runs at ${at} ${days === "1-5" ? "on weekdays" : days === "0-6" ? "every day" : `on days ${days}`}.`;
3439
+ },
3440
+ settings: (answer) => ({ DAILY_FOCUS_AGENT_AT: one(answer, "at"), DAILY_FOCUS_AGENT_DAYS: one(answer, "days") })
3441
+ };
3442
+ function proposedAccounts(proposal) {
3443
+ const raw = Array.isArray(proposal?.githubAccounts) ? proposal.githubAccounts : [];
3444
+ return raw.flatMap((entry) => {
3445
+ const account = typeof entry === "object" && entry !== null ? entry : {};
3446
+ const login = str3(account.login);
3447
+ return login ? [{ login, note: str3(account.note) }] : [];
3448
+ });
3449
+ }
3450
+ function githubAccounts(ctx) {
3451
+ const accounts = /* @__PURE__ */ new Map();
3452
+ for (const login of ctx.identity.logins) accounts.set(login.toLowerCase(), { login, note: "" });
3453
+ for (const { login, note } of proposedAccounts(proposalOf(ctx, "identity"))) {
3454
+ accounts.set(login.toLowerCase(), { login: accounts.get(login.toLowerCase())?.login ?? login, note });
3455
+ }
3456
+ return [...accounts.values()];
3457
+ }
3458
+ var identity2 = {
3459
+ id: "identity",
3460
+ topic: "who you are",
3461
+ title: () => "Is this you?",
3462
+ keys: [],
3463
+ // Clearing three fields and unticking the accounts is a chore; one button, named for what it does, isn't.
3464
+ 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." },
3465
+ card(ctx) {
3466
+ const proposal = proposalOf(ctx, "identity");
3467
+ const names = Array.isArray(proposal?.names) ? proposal.names.map(str3).filter(Boolean) : [];
3468
+ const name = names.length ? names.join(", ") : ctx.identity.name ?? "";
3469
+ const email = str3(proposal?.email) || (ctx.identity.email ?? "");
3470
+ const accounts = githubAccounts(ctx);
3471
+ return card({
3472
+ why: str3(proposal?.why) || "The brief tells your to-dos from everyone else\u2019s by these.",
3473
+ fields: [
3474
+ field("name", "Your name, as meeting notes write it", name, {
3475
+ placeholder: "Robin Lark",
3476
+ help: "Add short forms after a comma, such as Robin L."
3477
+ }),
3478
+ field("email", ctx.profile === "personal" ? "Your email" : "Your work email", email, { placeholder: "robin@example.com" })
3479
+ ],
3480
+ choices: [
3481
+ choice(
3482
+ "github",
3483
+ accounts.map((account) => option(account.login, account.login, account.note || null)),
3484
+ accounts.map((account) => account.login),
3485
+ { label: "Your GitHub accounts", multiple: true, custom: "Add a GitHub login" }
3486
+ )
3487
+ ]
3488
+ });
3489
+ },
3490
+ summary(answer) {
3491
+ const name = answer.fields.name?.split(",")[0]?.trim();
3492
+ const logins = answer.choices.github ?? [];
3493
+ const rest = [answer.fields.email?.trim() ?? "", logins.length ? `${listed(logins)} on GitHub` : ""].filter(Boolean);
3494
+ if (!name) return rest.length ? `You're ${listed(rest)}.` : "No details about you.";
3495
+ return `You're ${name}${rest.length ? `, ${rest.join(", ")}` : ""}.`;
3496
+ },
3497
+ settings(answer, ctx) {
3498
+ const values = {};
3499
+ if (answer.fields.email?.trim()) values.DAILY_FOCUS_CALENDAR_ADDRESSES = answer.fields.email.trim();
3500
+ const logins = answer.choices.github ?? [];
3501
+ if (logins.length > 1 || logins.length === 1 && logins[0] !== ctx.identity.logins[0]) {
3502
+ values.DAILY_FOCUS_GITHUB_ACCOUNTS = logins.join(", ");
3503
+ }
3504
+ return values;
3505
+ },
3506
+ sources(answer, ctx) {
3507
+ const names = (answer.fields.name ?? "").split(",").map((n) => n.trim()).filter(Boolean);
3508
+ const email = answer.fields.email?.trim();
3509
+ const notes = new Map(githubAccounts(ctx).map((account) => [account.login, account.note]));
3510
+ const logins = answer.choices.github ?? [];
3511
+ const lines = [
3512
+ 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,
3513
+ email ? `- ${ctx.profile === "personal" ? "Personal" : "Work"} email: ${ticked(email)}` : null,
3514
+ logins.length ? [
3515
+ `- GitHub ${logins.length > 1 ? "logins" : "login"}, which review requests and authorship are judged against:`,
3516
+ ...logins.map((login) => ` - ${ticked(login)}${notes.get(login) ? ` \u2014 ${notes.get(login)}` : ""}`)
3517
+ ].join("\n") : null
3518
+ ].filter((line) => line !== null);
3519
+ return lines.length ? [{ heading: "Identity", body: lines.join("\n") }] : [];
3520
+ },
3521
+ agent: {
3522
+ 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`.",
3523
+ facts: (ctx) => [
3524
+ ctx.identity.name ? `- gh says their name is ${ctx.identity.name}` : null,
3525
+ ctx.identity.logins.length ? `- gh is logged in to GitHub as ${listed(ctx.identity.logins)}; the first is active` : null,
3526
+ ctx.identity.email ? `- git's global email is ${ctx.identity.email}` : null
3527
+ ].filter(Boolean).join("\n") || null,
3528
+ schema: {
3529
+ type: "object",
3530
+ properties: {
3531
+ why: { type: "string", description: "One or two sentences on where these came from." },
3532
+ names: { type: "array", items: { type: "string" }, description: "Full name first, then short forms." },
3533
+ email: { type: "string" },
3534
+ githubAccounts: {
3535
+ type: "array",
3536
+ items: {
3537
+ type: "object",
3538
+ properties: {
3539
+ login: { type: "string" },
3540
+ note: { type: "string", description: "What the account is for, in a few words, or empty." }
3541
+ },
3542
+ required: ["login", "note"],
3543
+ additionalProperties: false
3544
+ }
3545
+ }
3546
+ },
3547
+ required: ["why", "names", "email", "githubAccounts"],
3548
+ additionalProperties: false
3549
+ }
3550
+ }
3551
+ };
3552
+ function healthText(board, via) {
3553
+ if (board.reason) return board.reason;
3554
+ if (board.fetchedAt) return `Connected through ${via}.`;
3555
+ if (board.enabled) return `Checking ${via}\u2026`;
3556
+ return null;
3557
+ }
3558
+ var boards = {
3559
+ id: "boards",
3560
+ topic: "the boards",
3561
+ title: () => "What else should the dashboard show?",
3562
+ keys: ["DAILY_FOCUS_GITHUB", "DAILY_FOCUS_JIRA"],
3563
+ later: null,
3564
+ card(ctx) {
3565
+ const { github, jira } = ctx.boards;
3566
+ const selected = [
3567
+ github.enabled && !github.reason ? "github" : null,
3568
+ jira.enabled && !jira.reason && ctx.profile === "work" ? "jira" : null
3569
+ ].filter((value) => value !== null);
3570
+ return card({
3571
+ why: "Both are read live, beside the brief, so a merge or a status change shows up within minutes.",
3572
+ choices: [
3573
+ choice(
3574
+ "boards",
3575
+ [
3576
+ option("github", "Your pull requests", healthText(github, "gh") ?? "Through the GitHub CLI, gh."),
3577
+ option("jira", "Your Jira tickets", healthText(jira, "acli") ?? "Through the Atlassian CLI, acli.")
3578
+ ],
3579
+ selected,
3580
+ { multiple: true }
3581
+ )
3582
+ ]
3583
+ });
3584
+ },
3585
+ summary(answer) {
3586
+ const on = answer.choices.boards ?? [];
3587
+ const names = [on.includes("github") ? "your pull requests" : null, on.includes("jira") ? "your Jira tickets" : null].filter(
3588
+ (name) => name !== null
3589
+ );
3590
+ return names.length ? `It shows ${listed(names)}.` : "No boards beside the brief.";
3591
+ },
3592
+ settings(answer) {
3593
+ const on = answer.choices.boards ?? [];
3594
+ return { DAILY_FOCUS_GITHUB: on.includes("github") ? "on" : "off", DAILY_FOCUS_JIRA: on.includes("jira") ? "on" : "off" };
3595
+ }
3596
+ };
3597
+ var HOLIDAYS = "Holidays";
3598
+ function calendarEntries(ctx) {
3599
+ const proposal = proposalOf(ctx, "calendars");
3600
+ const verdicts = Array.isArray(proposal?.calendars) ? proposal.calendars.flatMap((raw) => {
3601
+ const entry = typeof raw === "object" && raw !== null ? raw : {};
3602
+ const title = str3(entry.title);
3603
+ return title ? [{ title, account: str3(entry.account), include: entry.include === true, holiday: entry.holiday === true, briefName: str3(entry.briefName), note: str3(entry.note) }] : [];
3604
+ }) : [];
3605
+ const configured = new Set((ctx.current.DAILY_FOCUS_CALENDARS ?? "").split(",").map((name) => name.trim().toLowerCase()).filter(Boolean));
3606
+ const used = /* @__PURE__ */ new Set();
3607
+ const entries = [];
3608
+ for (const calendar of Array.isArray(ctx.calendars) ? ctx.calendars : []) {
3609
+ const key = calendar.title.toLowerCase();
3610
+ 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);
3611
+ if (verdict) used.add(verdict);
3612
+ entries.push({
3613
+ value: `live:${calendar.source}:${calendar.title}`,
3614
+ title: calendar.title,
3615
+ group: verdict?.holiday ? HOLIDAYS : calendar.source || "Calendar.app",
3616
+ live: true,
3617
+ holiday: verdict?.holiday ?? false,
3618
+ briefName: verdict?.briefName ?? "",
3619
+ note: verdict?.note ?? "",
3620
+ include: verdict ? verdict.include : configured.has(key)
3621
+ });
3622
+ }
3623
+ for (const verdict of verdicts) {
3624
+ if (used.has(verdict)) continue;
3625
+ entries.push({
3626
+ value: `brief:${verdict.account}:${verdict.title}`,
3627
+ title: verdict.title,
3628
+ group: verdict.holiday ? HOLIDAYS : verdict.account || "Your calendar account",
3629
+ live: false,
3630
+ holiday: verdict.holiday,
3631
+ briefName: verdict.briefName,
3632
+ note: verdict.note,
3633
+ include: verdict.include
3634
+ });
3635
+ }
3636
+ return entries.sort((a, b) => Number(a.group === HOLIDAYS) - Number(b.group === HOLIDAYS));
3637
+ }
3638
+ function chosenCalendars(answer, ctx) {
3639
+ const all = new Map(calendarEntries(ctx).map((entry) => [entry.value, entry]));
3640
+ const values = answer.choices.calendars ?? [];
3641
+ return {
3642
+ entries: values.flatMap((value) => all.get(value) ?? []),
3643
+ typed: values.filter((value) => !all.has(value)).map((value) => value.trim()).filter(Boolean)
3644
+ };
3645
+ }
3646
+ var calendars = {
3647
+ id: "calendars",
3648
+ topic: "your calendars",
3649
+ title: (ctx) => ctx.profile === "personal" ? "Which calendars belong in your brief?" : "Which calendars belong in your work brief?",
3650
+ keys: ["DAILY_FOCUS_CALENDARS"],
3651
+ later: null,
3652
+ card(ctx) {
3653
+ const entries = calendarEntries(ctx);
3654
+ const proposal = proposalOf(ctx, "calendars");
3655
+ const problem = ctx.calendar.supported && !ctx.calendar.built ? {
3656
+ 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.",
3657
+ command: ctx.calendar.buildCommand,
3658
+ blocking: false
3659
+ } : typeof ctx.calendars === "string" ? { message: `Calendar.app couldn't be read: ${ctx.calendars}`, command: null, blocking: false } : null;
3660
+ return card({
3661
+ why: str3(proposal?.why) || null,
3662
+ problem,
3663
+ choices: [
3664
+ choice(
3665
+ "calendars",
3666
+ entries.map((entry) => option(entry.value, entry.title, entry.note || null, entry.group)),
3667
+ entries.filter((entry) => entry.include).map((entry) => entry.value),
3668
+ { multiple: true, custom: "Add a calendar by name" }
3669
+ )
3670
+ ]
3671
+ });
3672
+ },
3673
+ summary(answer, ctx) {
3674
+ const { entries, typed } = chosenCalendars(answer, ctx);
3675
+ const names = [...entries.map((entry) => entry.title), ...typed];
3676
+ if (names.length === 0) return "No calendars.";
3677
+ if (names.length <= 3) return `It reads ${listed(names)}.`;
3678
+ return `It reads ${names.slice(0, 2).join(", ")} and ${names.length - 2} more calendars.`;
3679
+ },
3680
+ settings(answer, ctx) {
3681
+ const { entries, typed } = chosenCalendars(answer, ctx);
3682
+ const live = [.../* @__PURE__ */ new Set([...entries.filter((entry) => entry.live).map((entry) => entry.title), ...typed])];
3683
+ return { DAILY_FOCUS_CALENDARS: live.filter((name) => !name.includes(",")).join(", ") || null };
3684
+ },
3685
+ sources(answer, ctx) {
3686
+ const { entries, typed } = chosenCalendars(answer, ctx);
3687
+ const line = (entry) => `- ${ticked(entry.briefName || entry.title)}${entry.group !== HOLIDAYS && entry.group ? ` (${entry.group})` : ""}`;
3688
+ const own = [...entries.filter((entry) => !entry.holiday).map(line), ...typed.map((name) => `- ${ticked(name)}`)];
3689
+ const holidays = entries.filter((entry) => entry.holiday).map(line);
3690
+ return [
3691
+ ...own.length ? [{ heading: "Calendars", body: `Query these, and treat their events as blocking time:
3692
+
3693
+ ${own.join("\n")}` }] : [],
3694
+ ...holidays.length ? [{ heading: "Holiday calendars", body: `Call out a holiday today or within the next 14 days:
3695
+
3696
+ ${holidays.join("\n")}` }] : []
3697
+ ];
3698
+ },
3699
+ agent: {
3700
+ 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.',
3701
+ facts(ctx) {
3702
+ if (typeof ctx.calendars === "string") return `Calendar.app could not be read (${ctx.calendars}), so work from your calendar connector alone.`;
3703
+ if (!Array.isArray(ctx.calendars)) return "There is no list from Calendar.app here, so work from your calendar connector alone.";
3704
+ if (ctx.calendars.length === 0) return "Calendar.app has no calendars.";
3705
+ return [
3706
+ "Calendar.app has these, as title (account): events today.",
3707
+ "",
3708
+ ...ctx.calendars.map((calendar) => `- ${calendar.title} (${calendar.source || "no account"}): ${calendar.eventsToday}`)
3709
+ ].join("\n");
3710
+ },
3711
+ schema: {
3712
+ type: "object",
3713
+ properties: {
3714
+ why: { type: "string", description: "One or two sentences on which you ticked and why." },
3715
+ calendars: {
3716
+ type: "array",
3717
+ items: {
3718
+ type: "object",
3719
+ properties: {
3720
+ title: { type: "string", description: "Exactly as Calendar.app spells it, or the connector's name for one Calendar.app lacks." },
3721
+ account: { type: "string" },
3722
+ include: { type: "boolean" },
3723
+ holiday: { type: "boolean" },
3724
+ briefName: { type: "string" },
3725
+ note: { type: "string" }
3726
+ },
3727
+ required: ["title", "account", "include", "holiday", "briefName", "note"],
3728
+ additionalProperties: false
3729
+ }
3730
+ }
3731
+ },
3732
+ required: ["why", "calendars"],
3733
+ additionalProperties: false
3734
+ }
3735
+ }
3736
+ };
3737
+ function suggestedObjectives(proposal) {
3738
+ const raw = Array.isArray(proposal?.suggestions) ? proposal.suggestions : [];
3739
+ return raw.flatMap((entry) => {
3740
+ const suggestion = typeof entry === "object" && entry !== null ? entry : {};
3741
+ const text = str3(suggestion.objective);
3742
+ return text ? [{ text, detail: str3(suggestion.evidence) || null }] : [];
3743
+ });
3744
+ }
3745
+ var objective = {
3746
+ id: "objective",
3747
+ topic: "your objective",
3748
+ title: () => "What are you trying to get done right now?",
3749
+ keys: [],
3750
+ // Left empty, Continue says so; see `empty` on its card.
3751
+ later: null,
3752
+ card(ctx) {
3753
+ const proposal = proposalOf(ctx, "objective");
3754
+ return card({
3755
+ reply: "Tell it what you\u2019re working on, however roughly",
3756
+ empty: "Continue without one",
3757
+ 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.",
3758
+ fields: [
3759
+ field("objective", "Your objective", str3(proposal?.objective), {
3760
+ placeholder: "Get the staging deploy working again",
3761
+ suggestions: suggestedObjectives(proposal)
3762
+ }),
3763
+ field("blocker", "What\u2019s in the way, if anything", str3(proposal?.blocker), { placeholder: "Nobody can reproduce the failure" })
3764
+ ]
3765
+ });
3766
+ },
3767
+ summary: (answer) => {
3768
+ const text = answer.fields.objective?.trim();
3769
+ const blocker = answer.fields.blocker?.trim();
3770
+ if (!text) return "No objective for now.";
3771
+ return `Your objective: ${text}${blocker ? `. In the way: ${blocker}` : ""}`;
3772
+ },
3773
+ objective: (answer) => answer.fields.objective?.trim() ? { objective: answer.fields.objective.trim(), blocker: answer.fields.blocker?.trim() || null } : null,
3774
+ agent: {
3775
+ 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.`,
3776
+ schema: {
3777
+ type: "object",
3778
+ properties: {
3779
+ why: { type: "string", description: "One or two sentences: what you looked at, or how you wrote up what they said." },
3780
+ objective: { type: "string" },
3781
+ blocker: { type: "string" },
3782
+ suggestions: {
3783
+ type: "array",
3784
+ items: {
3785
+ type: "object",
3786
+ properties: {
3787
+ objective: { type: "string" },
3788
+ evidence: { type: "string", description: "What it was drawn from, in a few words." }
3789
+ },
3790
+ required: ["objective", "evidence"],
3791
+ additionalProperties: false
3792
+ }
3793
+ }
3794
+ },
3795
+ required: ["why", "objective", "blocker", "suggestions"],
3796
+ additionalProperties: false
3797
+ }
3798
+ }
3799
+ };
3800
+ var QUESTIONS = [cli, profile, schedule, identity2, boards, calendars, objective];
3801
+ function questionById(id) {
3802
+ return QUESTIONS.find((question) => question.id === id);
3803
+ }
3804
+ function composeSources(profile2, sections, today) {
3805
+ const intro = [
3806
+ "# Sources",
3807
+ "",
3808
+ `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.`
3809
+ ];
3810
+ return [...intro, ...sections.flatMap((section) => ["", `## ${section.heading}`, "", section.body])].join("\n") + "\n";
3811
+ }
3812
+ function machineName(platform = process.platform) {
3813
+ return platform === "darwin" ? "Mac" : platform === "win32" ? "PC" : "computer";
3814
+ }
3815
+
3025
3816
  // src/settings.ts
3026
3817
  var SETTING_GROUPS = [
3027
3818
  { id: "instance", title: "This dashboard" },
@@ -3091,6 +3882,14 @@ var SETTINGS = [
3091
3882
  choices: EFFORTS,
3092
3883
  fallback: always("The CLI's own")
3093
3884
  },
3885
+ {
3886
+ key: "DAILY_FOCUS_AGENT_SEARCH_TRASH",
3887
+ group: "agent",
3888
+ label: "Search deleted mail",
3889
+ help: "Lets the agent look in your mail\u2019s Trash as well as the inbox and archive. Off, it leaves out what you deleted.",
3890
+ kind: "flag",
3891
+ fallback: always("Off")
3892
+ },
3094
3893
  {
3095
3894
  key: "DAILY_FOCUS_AGENT_TOOLS",
3096
3895
  group: "agent",
@@ -3244,7 +4043,7 @@ var SETTINGS = [
3244
4043
  label: "Jira ticket board",
3245
4044
  help: "Flags tickets whose status disagrees with their pull requests, through the Atlassian CLI. Sign in with acli jira auth login.",
3246
4045
  kind: "flag",
3247
- fallback: (profile) => profile === "personal" ? "Off" : "On"
4046
+ fallback: (profile2) => profile2 === "personal" ? "Off" : "On"
3248
4047
  },
3249
4048
  {
3250
4049
  key: "DAILY_FOCUS_JIRA_PROJECTS",
@@ -3345,7 +4144,7 @@ var SETTINGS = [
3345
4144
  label: "Show free time",
3346
4145
  help: "Find focus windows between meetings in your working hours. Off shows events only.",
3347
4146
  kind: "flag",
3348
- fallback: (profile) => profile === "personal" ? "Off" : "On"
4147
+ fallback: (profile2) => profile2 === "personal" ? "Off" : "On"
3349
4148
  },
3350
4149
  {
3351
4150
  key: "DAILY_FOCUS_WORK_START",
@@ -3382,7 +4181,7 @@ function profileOf(sources, settings = sources.settings) {
3382
4181
  return PROFILES.includes(raw) ? raw : "work";
3383
4182
  }
3384
4183
  function describeSettings(sources) {
3385
- const profile = profileOf(sources);
4184
+ const profile2 = profileOf(sources);
3386
4185
  const settings = SETTINGS.map((def) => {
3387
4186
  const fromEnv = present(sources.process, def.key);
3388
4187
  const fromSettings = present(sources.settings, def.key);
@@ -3398,7 +4197,7 @@ function describeSettings(sources) {
3398
4197
  advanced: def.advanced === true,
3399
4198
  value,
3400
4199
  source,
3401
- fallback: def.fallback(profile)
4200
+ fallback: def.fallback(profile2)
3402
4201
  };
3403
4202
  });
3404
4203
  return { groups: SETTING_GROUPS, settings, error: sources.settingsError };
@@ -3431,134 +4230,758 @@ async function saveSettings(path, settings) {
3431
4230
  await writeJsonAtomic(path, sorted);
3432
4231
  }
3433
4232
 
3434
- // src/setup.ts
3435
- import { execFile as execFile4 } from "node:child_process";
3436
- import { access, constants, readdir as readdir2, readFile as readFile7, stat as stat3 } from "node:fs/promises";
3437
- import { delimiter, isAbsolute, join as join3 } from "node:path";
3438
- import { promisify as promisify4 } from "node:util";
3439
- var run4 = promisify4(execFile4);
3440
- var PLACEHOLDERS = [
3441
- "Your Name",
3442
- "you@example.com",
3443
- "you@personal.example",
3444
- "your-github-login",
3445
- "Some Shared Calendar",
3446
- "Some Noisy Calendar",
3447
- "<where your colleagues are>",
3448
- "<where you are>",
3449
- "<where you live>",
3450
- "<document id>"
4233
+ // src/onboarding.ts
4234
+ var TURN_TIMEOUT_MS2 = 4 * 6e4;
4235
+ var KILL_AFTER_MS = 5e3;
4236
+ var STEPS_KEPT = 40;
4237
+ var SESSION_MODEL = {
4238
+ claude: { model: "opus", effort: "low" },
4239
+ codex: { model: null, effort: "low" }
4240
+ };
4241
+ var ONBOARDING_TOOLS = [
4242
+ "Bash(gh *)",
4243
+ "Bash(acli *)",
4244
+ "Bash(git config *)",
4245
+ "mcp__claude_ai_Gmail__search_threads",
4246
+ "mcp__claude_ai_Gmail__get_thread",
4247
+ "mcp__claude_ai_Gmail__get_message",
4248
+ "mcp__claude_ai_Gmail__list_labels",
4249
+ "mcp__claude_ai_Google_Calendar__list_calendars",
4250
+ "mcp__claude_ai_Google_Calendar__list_events",
4251
+ "mcp__claude_ai_Google_Calendar__search_events",
4252
+ "mcp__claude_ai_Google_Calendar__get_event",
4253
+ "mcp__claude_ai_Google_Drive__search_files",
4254
+ "mcp__claude_ai_Google_Drive__list_recent_files",
4255
+ "mcp__claude_ai_Google_Drive__get_file_metadata",
4256
+ "mcp__claude_ai_Google_Drive__read_file_content"
3451
4257
  ];
3452
- function placeholdersIn(text) {
3453
- return PLACEHOLDERS.filter((placeholder) => text.includes(placeholder)).length;
4258
+ var RESTARTED = "the dashboard restarted while this was running";
4259
+ function blankFile() {
4260
+ return { startedAt: (/* @__PURE__ */ new Date()).toISOString(), finishedAt: null, briefRequested: false, answers: {}, proposals: {} };
3454
4261
  }
3455
- async function findExecutable(name, path = process.env.PATH ?? "") {
3456
- const candidates = isAbsolute(name) ? [name] : path.split(delimiter).filter(Boolean).map((dir) => join3(dir, name));
3457
- for (const candidate of candidates) {
3458
- try {
3459
- await access(candidate, constants.X_OK);
3460
- if ((await stat3(candidate)).isFile()) return candidate;
3461
- } catch {
4262
+ function parseFile(text) {
4263
+ const file = blankFile();
4264
+ let raw;
4265
+ try {
4266
+ raw = JSON.parse(text);
4267
+ } catch {
4268
+ return file;
4269
+ }
4270
+ if (typeof raw !== "object" || raw === null) return file;
4271
+ const obj = raw;
4272
+ if (typeof obj.startedAt === "string") file.startedAt = obj.startedAt;
4273
+ if (typeof obj.finishedAt === "string") file.finishedAt = obj.finishedAt;
4274
+ file.briefRequested = obj.briefRequested === true;
4275
+ if (typeof obj.answers === "object" && obj.answers !== null) {
4276
+ for (const [id, answer] of Object.entries(obj.answers)) {
4277
+ const clean = cleanAnswer(answer);
4278
+ if (clean) file.answers[id] = clean;
3462
4279
  }
3463
4280
  }
3464
- return null;
3465
- }
3466
- async function findClis(config) {
3467
- const found = {};
3468
- for (const cli of CLI_NAMES) {
3469
- const candidates = [
3470
- config.agent.cli === cli ? config.agent.binPath : null,
3471
- config.assistant.agent === cli ? config.assistant.binPath : null,
3472
- cli
3473
- ].filter((candidate) => candidate !== null);
3474
- found[cli] = null;
3475
- for (const candidate of candidates) {
3476
- found[cli] = await findExecutable(candidate);
3477
- if (found[cli]) break;
4281
+ if (typeof obj.proposals === "object" && obj.proposals !== null) {
4282
+ for (const [id, value] of Object.entries(obj.proposals)) {
4283
+ const record = value;
4284
+ if (!record || !CLI_NAMES.includes(record.cli) || !PROFILES.includes(record.profile)) continue;
4285
+ file.proposals[id] = {
4286
+ cli: record.cli,
4287
+ profile: record.profile,
4288
+ sessionId: typeof record.sessionId === "string" ? record.sessionId : null,
4289
+ status: record.status === "ready" || record.status === "failed" ? record.status : "working",
4290
+ result: record.result ?? null,
4291
+ error: typeof record.error === "string" ? record.error : null,
4292
+ version: typeof record.version === "number" ? record.version : 0,
4293
+ conversation: Array.isArray(record.conversation) ? record.conversation.filter(
4294
+ (line) => typeof line === "object" && line !== null && (line.from === "you" || line.from === "it") && typeof line.text === "string"
4295
+ ) : []
4296
+ };
3478
4297
  }
3479
4298
  }
3480
- return found;
3481
- }
3482
- async function hasArchive(config) {
3483
- try {
3484
- return (await readdir2(config.archiveDir)).some((name) => name.endsWith(".json"));
3485
- } catch {
3486
- return false;
4299
+ return file;
4300
+ }
4301
+ function cleanAnswer(value) {
4302
+ if (typeof value !== "object" || value === null) return null;
4303
+ const obj = value;
4304
+ if (obj.later === true) return { later: true };
4305
+ const choices = {};
4306
+ const fields = {};
4307
+ if (typeof obj.choices === "object" && obj.choices !== null) {
4308
+ for (const [id, values] of Object.entries(obj.choices)) {
4309
+ if (Array.isArray(values)) choices[id] = [...new Set(values.filter((v) => typeof v === "string" && v.trim() !== ""))];
4310
+ }
3487
4311
  }
3488
- }
3489
- async function exists(path) {
3490
- try {
3491
- await stat3(path);
3492
- return true;
3493
- } catch {
3494
- return false;
4312
+ if (typeof obj.fields === "object" && obj.fields !== null) {
4313
+ for (const [id, text] of Object.entries(obj.fields)) {
4314
+ if (typeof text === "string") fields[id] = text;
4315
+ }
3495
4316
  }
4317
+ return { choices, fields };
3496
4318
  }
3497
- async function firstBriefPending(config) {
3498
- return !await exists(config.itemsFile) && !await hasArchive(config);
4319
+ function given(answer) {
4320
+ return answer && answer.later !== true ? answer : null;
3499
4321
  }
3500
- async function setupState(config, opts) {
3501
- let sources = null;
3502
- try {
3503
- sources = await readFile7(config.sourcesFile, "utf8");
3504
- } catch {
3505
- sources = null;
3506
- }
3507
- const supported = process.platform === "darwin";
4322
+ function answerFromCard(card2) {
3508
4323
  return {
3509
- // The file, not whether it parsed: a brief that is there but broken is a
3510
- // problem for its banner to report, not a store to set up from scratch. The
3511
- // agent's clock asks the same question.
3512
- needed: await firstBriefPending(config),
3513
- profile: config.profile,
3514
- profileChosen: opts.profileChosen,
3515
- sources: { exists: sources !== null, placeholders: sources === null ? 0 : placeholdersIn(sources) },
3516
- clis: await findClis(config),
3517
- calendar: {
3518
- supported,
3519
- built: supported && await exists(config.calendar.appPath),
3520
- chosen: config.calendar.names.length,
3521
- buildCommand: command("build-calendar")
3522
- }
4324
+ choices: Object.fromEntries(card2.choices.map((choice2) => [choice2.id, choice2.selected])),
4325
+ fields: Object.fromEntries(card2.fields.map((field2) => [field2.id, field2.value]))
3523
4326
  };
3524
4327
  }
3525
- var identity = null;
3526
- function detectIdentity(config) {
3527
- identity ??= (async () => {
3528
- const [user, email] = await Promise.all([
3529
- run4(config.github.ghPath, ["api", "user", "--jq", '[.login, .name // ""] | join("\\n")'], { timeout: 5e3 }).then(
3530
- ({ stdout }) => stdout.split("\n"),
3531
- () => []
3532
- ),
3533
- run4("git", ["config", "--global", "user.email"], { timeout: 2e3 }).then(
3534
- ({ stdout }) => stdout.trim() || null,
3535
- () => null
3536
- )
3537
- ]);
4328
+ var Onboarding = class {
4329
+ file = null;
4330
+ loaded = false;
4331
+ running = /* @__PURE__ */ new Map();
4332
+ steps = /* @__PURE__ */ new Map();
4333
+ /** Calendar.app's calendars being listed, which only starting the sessions waits for. */
4334
+ calendars = null;
4335
+ /** What listing them found, once it has: the calendars, or why they couldn't be listed. */
4336
+ listed = null;
4337
+ finishError = null;
4338
+ config;
4339
+ deps;
4340
+ spawnFn;
4341
+ gates = /* @__PURE__ */ new Map();
4342
+ codexOff = null;
4343
+ codexModel = null;
4344
+ /** What looking for the CLIs found, once the screen has asked. Not kept: what is installed changes. */
4345
+ detected = null;
4346
+ detecting = null;
4347
+ /** The pass starting sessions, which the next one waits for; see `startProposals`. */
4348
+ starting = Promise.resolve();
4349
+ /** The write in progress, which the next one waits for. */
4350
+ writes = Promise.resolve();
4351
+ constructor(config, deps) {
4352
+ this.config = config;
4353
+ this.deps = deps;
4354
+ this.spawnFn = deps.spawnFn ?? spawn2;
4355
+ }
4356
+ /** Whether a session has a process going. A restart waits for none. */
4357
+ get busy() {
4358
+ return this.running.size > 0;
4359
+ }
4360
+ /**
4361
+ * Read the file, and start again any session the last process left going.
4362
+ * Only while setup is still to do: `needed` is the caller's `firstBriefPending`.
4363
+ */
4364
+ async start(needed) {
4365
+ await this.load();
4366
+ if (!needed || this.file?.finishedAt) return;
4367
+ for (const record of Object.values(this.file?.proposals ?? {})) {
4368
+ if (record.status === "working") {
4369
+ record.status = "failed";
4370
+ record.error = RESTARTED;
4371
+ }
4372
+ }
4373
+ this.startProposals();
4374
+ }
4375
+ /** The screen, while setup is to do: the store has never had a brief, and setup hasn't finished. */
4376
+ async view(setup) {
4377
+ await this.load();
4378
+ if (!setup.needed || this.file?.finishedAt) return null;
4379
+ const file = this.file ?? blankFile();
4380
+ const ctx = await this.context({ setup });
4381
+ const sources = this.deps.readSources();
4382
+ const answers = this.effective(ctx, file.answers);
4383
+ const steps = [];
4384
+ for (const question of this.questions(ctx)) {
4385
+ const locked = this.locked(question, sources);
4386
+ const drawn = question.card(ctx);
4387
+ const record = file.proposals[question.id];
4388
+ const answer = locked ? question.id === "cli" ? { choices: { cli: ctx.cli ? [ctx.cli] : [] }, fields: {} } : answerFromCard(drawn) : answers[question.id] ?? null;
4389
+ const chosen = given(answer);
4390
+ const looking = question.id === "cli" && !answer && !this.detected;
4391
+ steps.push({
4392
+ id: question.id,
4393
+ title: question.title(ctx),
4394
+ topic: question.topic,
4395
+ status: answer ? chosen ? "answered" : "later" : "open",
4396
+ summary: chosen ? question.summary(chosen, ctx) : answer ? question.later?.summary ?? null : null,
4397
+ locked,
4398
+ card: { ...drawn, version: record?.version ?? 0, revisable: Boolean(question.agent && record), later: question.later?.label ?? null },
4399
+ proposal: looking ? {
4400
+ status: "working",
4401
+ conversation: [],
4402
+ steps: [{ kind: "tool", text: "Look for Codex and Claude Code", detail: null, state: "running" }],
4403
+ error: null
4404
+ } : record ? {
4405
+ status: record.status,
4406
+ conversation: record.conversation,
4407
+ steps: this.steps.get(question.id) ?? [],
4408
+ error: record.status === "failed" ? record.error : null
4409
+ } : null,
4410
+ answer
4411
+ });
4412
+ }
3538
4413
  return {
3539
- login: config.github.accounts[0] ?? (user[0]?.trim() || null),
3540
- name: user[1]?.trim() || null,
3541
- email
4414
+ detected: this.detected !== null,
4415
+ steps,
4416
+ current: steps.find((step) => step.status === "open")?.id ?? null,
4417
+ error: this.finishError
3542
4418
  };
3543
- })();
3544
- return identity;
3545
- }
3546
- function fillIdentity(template, who) {
3547
- let text = template;
3548
- if (who.name) text = text.replaceAll("`Your Name`", `\`${who.name}\``);
3549
- if (who.email) text = text.replaceAll("`you@example.com`", `\`${who.email}\``).replaceAll("`you@personal.example`", `\`${who.email}\``);
3550
- if (who.login) text = text.replaceAll("`your-github-login`", `\`${who.login}\``);
3551
- return text;
4419
+ }
4420
+ /**
4421
+ * Look for the CLIs, which the screen asks for when it opens: with one ready,
4422
+ * the first question answers itself. Asked again by Check again.
4423
+ */
4424
+ async detect() {
4425
+ await this.load();
4426
+ await this.lookForClis(true);
4427
+ this.startProposals();
4428
+ this.deps.onChange();
4429
+ }
4430
+ /**
4431
+ * Record an answer, or that the user would rather decide later. One whose
4432
+ * settings the dashboard would refuse is refused here, while the card that
4433
+ * can fix it is still open.
4434
+ */
4435
+ async answer(id, raw) {
4436
+ await this.load();
4437
+ const file = this.file ??= blankFile();
4438
+ if (file.finishedAt) return "setup has finished";
4439
+ const question = questionById(id);
4440
+ let answer = cleanAnswer(raw);
4441
+ if (!question || !answer) return `there is no question ${JSON.stringify(id)}, or that is not an answer to it`;
4442
+ if (!given(answer) && !question.later) return `"${question.topic}" needs an answer`;
4443
+ if (id === "cli" && given(answer)) {
4444
+ const chosen = await this.chooseCli(given(answer));
4445
+ if (typeof chosen === "string") return chosen;
4446
+ answer = chosen;
4447
+ }
4448
+ const ctx = await this.context();
4449
+ const before = { cli: ctx.cli, profile: ctx.profile };
4450
+ if (given(answer) && question.settings) {
4451
+ const refused = this.deps.checkSettings(this.settingsOf({ ...file.answers, [id]: answer }, await this.context({ answers: { ...file.answers, [id]: answer } })));
4452
+ if (refused) return refused;
4453
+ }
4454
+ file.answers[id] = answer;
4455
+ this.finishError = null;
4456
+ await this.stop(id, "you continued before it finished");
4457
+ const after = await this.context();
4458
+ if (after.profile !== before.profile) {
4459
+ for (const other of QUESTIONS) {
4460
+ if (!other.agent) continue;
4461
+ delete file.answers[other.id];
4462
+ await this.drop(other.id);
4463
+ }
4464
+ } else if (after.cli !== before.cli) {
4465
+ for (const other of QUESTIONS) if (other.agent && !file.answers[other.id]) await this.drop(other.id);
4466
+ }
4467
+ await this.save();
4468
+ this.startProposals();
4469
+ this.deps.onChange();
4470
+ return null;
4471
+ }
4472
+ /** Tell a question's session what to change. `draft` is what the card holds now, which it should start from. */
4473
+ async revise(id, text, draft) {
4474
+ await this.load();
4475
+ const question = questionById(id);
4476
+ const record = this.file?.proposals[id];
4477
+ if (!question?.agent || !record) return "nothing is looking into that question";
4478
+ if (this.running.has(id)) return "it is still working on that question";
4479
+ const ctx = await this.context();
4480
+ const current = cleanAnswer(draft);
4481
+ const request = [
4482
+ `The user says: ${text.trim()}`,
4483
+ ...given(current) ? ["", "What the card holds now, which they may have changed:", "", "```json", JSON.stringify(current), "```"] : [],
4484
+ "",
4485
+ "Revise your proposal, and answer with the whole of it again."
4486
+ ].join("\n");
4487
+ const resume = record.sessionId && record.cli === ctx.cli ? record.sessionId : null;
4488
+ const said = [...record.conversation, { from: "you", text: text.trim() }];
4489
+ await this.ask(question, ctx, resume ? request : `${await this.firstMessage(question, ctx)}
4490
+
4491
+ ${request}`, resume, said);
4492
+ return null;
4493
+ }
4494
+ /**
4495
+ * Look again: ask a question's session again from the start, after it failed,
4496
+ * and list Calendar.app's calendars again for the question that shows them,
4497
+ * since what failed may have been the access the user has since granted.
4498
+ */
4499
+ async retry(id) {
4500
+ await this.load();
4501
+ const question = questionById(id);
4502
+ if (!question) return `there is no question ${JSON.stringify(id)}`;
4503
+ if (this.running.has(id)) return "it is still working on that question";
4504
+ if (id === "calendars") {
4505
+ this.calendars = null;
4506
+ this.listed = null;
4507
+ }
4508
+ if (id === "cli") await this.lookForClis(true);
4509
+ if (question.agent) await this.drop(id);
4510
+ this.startProposals();
4511
+ this.deps.onChange();
4512
+ return null;
4513
+ }
4514
+ /**
4515
+ * Save everything, and ask for the first brief. Files first, then the
4516
+ * settings, whose save restarts the dashboard: the start after it begins the
4517
+ * run. Resolves with why not, or null.
4518
+ */
4519
+ async finish() {
4520
+ await this.load();
4521
+ const file = this.file ??= blankFile();
4522
+ if (file.finishedAt) return { error: "setup has already finished" };
4523
+ if (!file.answers.cli) await this.lookForClis(false);
4524
+ const ctx = await this.context();
4525
+ const sources = this.deps.readSources();
4526
+ const answers = this.effective(ctx, file.answers);
4527
+ const sections = [];
4528
+ let objective2 = null;
4529
+ for (const question of this.questions(ctx)) {
4530
+ const answer = answers[question.id];
4531
+ if (!answer && !this.locked(question, sources)) return this.refuse(`answer "${question.title(ctx)}", or decide it later`);
4532
+ const chosen = this.locked(question, sources) ? null : given(answer);
4533
+ if (!chosen) continue;
4534
+ sections.push(...question.sources?.(chosen, ctx) ?? []);
4535
+ objective2 = question.objective?.(chosen) ?? objective2;
4536
+ }
4537
+ const values = this.settingsOf(file.answers, ctx);
4538
+ const refused = this.deps.checkSettings(values);
4539
+ if (refused) return this.refuse(refused);
4540
+ await this.starting;
4541
+ for (const id of [...this.running.keys()]) await this.stop(id, "setup finished");
4542
+ const existing = await readEditable(this.config.sourcesFile);
4543
+ if (existing.text === null && sections.length > 0) {
4544
+ await mkdir2(this.config.dataDir, { recursive: true });
4545
+ await saveEditable(this.config.sourcesFile, composeSources(ctx.profile, sections, (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)), ABSENT);
4546
+ }
4547
+ if (objective2) {
4548
+ const focus = await readEditable(this.config.focusFile);
4549
+ const saved = await saveEditable(this.config.focusFile, setFocusFields(focus.text, objective2, FOCUS_TEMPLATE), focus.version);
4550
+ if (!saved.saved) return this.refuse("focus.md changed on disk while saving; finish again");
4551
+ }
4552
+ file.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
4553
+ file.briefRequested = ctx.cli !== null;
4554
+ await this.save();
4555
+ const before = JSON.stringify(Object.entries(sources.settings).sort());
4556
+ const saveRefused = await this.deps.saveSettings(values);
4557
+ if (saveRefused) {
4558
+ file.finishedAt = null;
4559
+ file.briefRequested = false;
4560
+ await this.save();
4561
+ return this.refuse(saveRefused);
4562
+ }
4563
+ this.finishError = null;
4564
+ this.deps.onChange();
4565
+ return { restart: JSON.stringify(Object.entries(this.deps.readSources().settings).sort()) !== before };
4566
+ }
4567
+ /**
4568
+ * Whether the finish asked for a first brief that hasn't been started, and
4569
+ * forget it: asked once, so a run that fails is retried by the user, not by
4570
+ * every start.
4571
+ */
4572
+ async takeBriefRequest() {
4573
+ await this.load();
4574
+ if (!this.file?.briefRequested) return false;
4575
+ this.file.briefRequested = false;
4576
+ await this.save();
4577
+ return true;
4578
+ }
4579
+ /** What the finish would write as the source list, or null when the store already has one it would keep. */
4580
+ async previewSources() {
4581
+ await this.load();
4582
+ const existing = await readEditable(this.config.sourcesFile);
4583
+ if (existing.text !== null) return { text: null, kept: true };
4584
+ const ctx = await this.context();
4585
+ const sections = [];
4586
+ for (const question of this.questions(ctx)) {
4587
+ const chosen = given(this.file?.answers[question.id]);
4588
+ if (chosen) sections.push(...question.sources?.(chosen, ctx) ?? []);
4589
+ }
4590
+ return { text: sections.length ? composeSources(ctx.profile, sections, (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)) : null, kept: false };
4591
+ }
4592
+ /** Stop every session, for shutdown, and wait until each has exited and been written down. */
4593
+ async close() {
4594
+ await this.starting;
4595
+ const over = [...this.running.values()].map((run5) => run5.over);
4596
+ for (const id of [...this.running.keys()]) await this.stop(id, RESTARTED);
4597
+ await Promise.all(over);
4598
+ }
4599
+ /* ---------- the sessions ---------- */
4600
+ /**
4601
+ * Start whatever sessions the answers so far call for, without holding up the
4602
+ * reply that asked: starting one under Codex runs a few checks first, and a
4603
+ * screen waiting on them has every button greyed out with nothing to say why.
4604
+ * One pass at a time, so two quick answers can't both start the same question.
4605
+ */
4606
+ startProposals() {
4607
+ this.starting = this.starting.then(() => this.ensureProposals()).catch((err) => console.error(`[daily-focus] setup could not start looking: ${err.message}`));
4608
+ }
4609
+ /** Resolves once the sessions asked for so far have started: for tests, and for anything that must not race them. */
4610
+ started() {
4611
+ return this.starting;
4612
+ }
4613
+ /** Start a session for each question that has one and needs it: the CLI and the profile are known, and nobody has asked yet. */
4614
+ async ensureProposals() {
4615
+ const file = this.file;
4616
+ if (!file || file.finishedAt) return;
4617
+ if (Object.keys(file.answers).length === 0) return;
4618
+ const set = (this.deps.readSources().process.DAILY_FOCUS_AGENT ?? "").trim();
4619
+ if (file.answers.cli && !given(file.answers.cli) || set && !CLI_NAMES.includes(set)) return;
4620
+ await this.lookForClis(false);
4621
+ const ctx = await this.context({ listCalendars: true });
4622
+ const sources = this.deps.readSources();
4623
+ const profileKnown = given(file.answers.profile) !== null || (sources.process.DAILY_FOCUS_PROFILE ?? "").trim() !== "";
4624
+ if (!ctx.cli || !profileKnown) return;
4625
+ for (const question of this.questions(ctx)) {
4626
+ if (!question.agent || file.answers[question.id] || this.running.has(question.id)) continue;
4627
+ const record = file.proposals[question.id];
4628
+ if (record && record.cli === ctx.cli && record.profile === ctx.profile && record.status !== "failed") continue;
4629
+ if (record?.status === "failed" && record.error !== RESTARTED && record.cli === ctx.cli && record.profile === ctx.profile) continue;
4630
+ await this.ask(question, ctx, await this.firstMessage(question, ctx), null);
4631
+ }
4632
+ }
4633
+ /** The first message of a question's session: the instructions, what is known, and the question. */
4634
+ async firstMessage(question, ctx) {
4635
+ const instructions = await readFile8(this.config.onboardingPromptSource, "utf8").catch(
4636
+ () => "You are helping someone set up Daily Focus. Only read; never send, post or edit anything. Answer with the JSON the schema asks for."
4637
+ );
4638
+ const agent = question.agent;
4639
+ const facts = agent.facts?.(ctx) ?? null;
4640
+ return [
4641
+ instructions.trim(),
4642
+ "",
4643
+ "## This setup",
4644
+ "",
4645
+ `- Profile: ${ctx.profile}`,
4646
+ `- Today: ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`,
4647
+ "",
4648
+ `## The question: ${question.title(ctx)}`,
4649
+ "",
4650
+ agent.brief(ctx),
4651
+ ...facts ? ["", "### What the dashboard already knows", "", facts] : []
4652
+ ].join("\n");
4653
+ }
4654
+ async ask(question, ctx, prompt, resume, conversation = []) {
4655
+ const file = this.file ??= blankFile();
4656
+ const cli2 = ctx.cli;
4657
+ const previous = file.proposals[question.id];
4658
+ const record = {
4659
+ cli: cli2,
4660
+ profile: ctx.profile,
4661
+ sessionId: resume,
4662
+ status: "working",
4663
+ result: previous?.cli === cli2 && previous.profile === ctx.profile ? previous.result : null,
4664
+ error: null,
4665
+ version: previous?.version ?? 0,
4666
+ conversation
4667
+ };
4668
+ file.proposals[question.id] = record;
4669
+ this.steps.set(question.id, []);
4670
+ await this.save();
4671
+ this.deps.onChange();
4672
+ const bin = ctx.clis?.[cli2]?.path ?? null;
4673
+ if (!bin) return this.settle(question.id, record, { ok: false, error: `could not find ${cli2} on this machine` });
4674
+ if (cli2 === "codex") {
4675
+ const gate = this.gates.get(bin) ?? new CodexGate(bin, this.deps.execFn);
4676
+ this.gates.set(bin, gate);
4677
+ const refusal2 = await gate.refusal();
4678
+ if (refusal2) return this.settle(question.id, record, { ok: false, error: refusal2 });
4679
+ }
4680
+ let codexConfig = [];
4681
+ if (cli2 === "codex") {
4682
+ try {
4683
+ this.codexOff ??= computerUseOff(bin, this.deps.execFn);
4684
+ codexConfig = await this.codexOff;
4685
+ } catch (err) {
4686
+ this.codexOff = null;
4687
+ return this.settle(question.id, record, { ok: false, error: `Codex could not say which plugins to switch off: ${err.message}` });
4688
+ }
4689
+ }
4690
+ const schemaDir = await mkdtemp(join5(tmpdir2(), "daily-focus-setup-"));
4691
+ const schemaFile = join5(schemaDir, "schema.json");
4692
+ const json = JSON.stringify(question.agent.schema);
4693
+ await writeFile2(schemaFile, json, "utf8");
4694
+ await mkdir2(this.config.onboardingDir, { recursive: true });
4695
+ const adapter = ADAPTERS[cli2];
4696
+ const session = SESSION_MODEL[cli2];
4697
+ const model = cli2 === "codex" ? await (this.codexModel ??= codexLatestModel(bin, this.deps.execFn)) : session.model;
4698
+ const args = adapter.args({
4699
+ prompt,
4700
+ sessionId: resume,
4701
+ model,
4702
+ effort: session.effort,
4703
+ tools: ONBOARDING_TOOLS,
4704
+ denyEdits: true,
4705
+ drafts: false,
4706
+ codexConfig,
4707
+ schema: { json, file: schemaFile }
4708
+ });
4709
+ let child;
4710
+ try {
4711
+ child = this.spawnFn(bin, args, { cwd: this.config.onboardingDir, stdio: ["ignore", "pipe", "pipe"], env: process.env });
4712
+ } catch (err) {
4713
+ await rm2(schemaDir, { recursive: true, force: true });
4714
+ return this.settle(question.id, record, { ok: false, error: `could not run ${bin}: ${err.message}` });
4715
+ }
4716
+ const timer = setTimeout(() => void this.stop(question.id, "gave up after four minutes"), TURN_TIMEOUT_MS2);
4717
+ timer.unref();
4718
+ let ended;
4719
+ const run5 = { child, timer, abortReason: null, over: new Promise((resolve2) => ended = resolve2) };
4720
+ this.running.set(question.id, run5);
4721
+ let done;
4722
+ let said = "";
4723
+ const stderr = [];
4724
+ child.stderr?.on("data", (chunk) => {
4725
+ stderr.push(chunk.toString("utf8"));
4726
+ if (stderr.length > 50) stderr.shift();
4727
+ });
4728
+ const calls = /* @__PURE__ */ new Map();
4729
+ const steps = this.steps.get(question.id);
4730
+ const add = (step) => {
4731
+ steps.push(step);
4732
+ if (steps.length > STEPS_KEPT) steps.shift();
4733
+ };
4734
+ if (child.stdout) {
4735
+ createInterface2({ input: child.stdout }).on("line", (line) => {
4736
+ const event = adapter.parse(line);
4737
+ if (!event) return;
4738
+ if (event.sessionId) record.sessionId = event.sessionId;
4739
+ if (event.text) said = event.text;
4740
+ for (const parsed of event.steps ?? []) {
4741
+ const known = calls.get(parsed.id);
4742
+ if (known) {
4743
+ if (parsed.text) known.text = parsed.text;
4744
+ if (parsed.detail) known.detail = parsed.detail;
4745
+ known.state = parsed.state;
4746
+ } else if (parsed.text) {
4747
+ const step = { kind: "tool", text: parsed.text, detail: parsed.detail ?? null, state: parsed.state };
4748
+ calls.set(parsed.id, step);
4749
+ add(step);
4750
+ }
4751
+ }
4752
+ if (event.steps) this.deps.onChange();
4753
+ if (event.done) done = event.done;
4754
+ });
4755
+ }
4756
+ child.on("error", (err) => {
4757
+ done = { ok: false, error: `could not run ${bin}: ${err.message}` };
4758
+ });
4759
+ child.on("exit", () => {
4760
+ if (run5.abortReason === null) return;
4761
+ child.stdout?.destroy();
4762
+ child.stderr?.destroy();
4763
+ });
4764
+ child.on("close", (code) => {
4765
+ clearTimeout(timer);
4766
+ this.running.delete(question.id);
4767
+ void rm2(schemaDir, { recursive: true, force: true });
4768
+ const outcome = run5.abortReason ? { ok: false, error: run5.abortReason } : done ?? {
4769
+ ok: false,
4770
+ error: `${cli2} exited with code ${code}${stderr.length ? `: ${stderr.join("").trim().split("\n").slice(-3).join(" ")}` : ""}`
4771
+ };
4772
+ 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);
4773
+ });
4774
+ }
4775
+ /** A session is over: keep its answer when it is JSON, or why it isn't. */
4776
+ async settle(id, record, outcome) {
4777
+ if (this.file?.proposals[id] !== record) return;
4778
+ for (const step of this.steps.get(id) ?? []) if (step.state === "running") step.state = outcome.ok ? "done" : "failed";
4779
+ if (outcome.ok) {
4780
+ try {
4781
+ record.result = JSON.parse(stripFence(outcome.reply ?? ""));
4782
+ record.status = "ready";
4783
+ record.error = null;
4784
+ record.version += 1;
4785
+ const why = record.result?.why;
4786
+ if (typeof why === "string" && why.trim()) record.conversation.push({ from: "it", text: why.trim() });
4787
+ } catch {
4788
+ record.status = "failed";
4789
+ record.error = "it answered, but not in the shape asked for";
4790
+ }
4791
+ } else {
4792
+ record.status = "failed";
4793
+ record.error = outcome.error ?? "failed";
4794
+ }
4795
+ await this.save();
4796
+ this.deps.onChange();
4797
+ }
4798
+ /** Stop a session: asked to, then made to if it hasn't gone within a few seconds. */
4799
+ async stop(id, reason) {
4800
+ const run5 = this.running.get(id);
4801
+ if (!run5 || run5.abortReason !== null) return;
4802
+ run5.abortReason = reason;
4803
+ run5.child.kill("SIGTERM");
4804
+ const kill = setTimeout(() => {
4805
+ if (run5.child.exitCode === null && run5.child.signalCode === null) run5.child.kill("SIGKILL");
4806
+ }, KILL_AFTER_MS);
4807
+ kill.unref();
4808
+ void run5.over.then(() => clearTimeout(kill));
4809
+ }
4810
+ /** Forget a question's proposal, stopping its session first. */
4811
+ async drop(id) {
4812
+ await this.stop(id, "no longer needed");
4813
+ if (this.file) delete this.file.proposals[id];
4814
+ this.steps.delete(id);
4815
+ }
4816
+ /* ---------- what a question may read ---------- */
4817
+ /**
4818
+ * Look for the CLIs, once per process unless `again`. Only from a click or
4819
+ * from setup already under way: `--version` and a login check are programs
4820
+ * run on the user's behalf, and a new store's state is read before anyone is
4821
+ * at the screen.
4822
+ */
4823
+ async lookForClis(again) {
4824
+ if (this.detected && !again) return;
4825
+ this.detecting ??= this.deps.detectClis().finally(() => {
4826
+ this.detecting = null;
4827
+ });
4828
+ this.detected = await this.detecting;
4829
+ }
4830
+ /**
4831
+ * The CLI answer to keep: one of the ready ones, or a path the user typed,
4832
+ * which is used only once it has said what it is and that it is ready.
4833
+ * Otherwise why not, in a sentence the card shows.
4834
+ */
4835
+ async chooseCli(answer) {
4836
+ const typed = answer.fields.path?.trim();
4837
+ if (!typed) {
4838
+ const picked = answer.choices.cli?.[0];
4839
+ await this.lookForClis(false);
4840
+ if (picked && this.detected?.[picked]?.ready) return { choices: { cli: [picked] }, fields: {} };
4841
+ return "Enter the path to codex or claude, or decide this later";
4842
+ }
4843
+ const { cli: cli2, found } = await this.deps.probeCli(expandHome(typed));
4844
+ if (!cli2 || !found.ready) {
4845
+ return `${found.problem ?? "That can't be used"}${found.fix ? `: run ${found.fix} in a terminal, then try again.` : "."}`;
4846
+ }
4847
+ await this.lookForClis(false);
4848
+ this.detected = { ...this.detected, [cli2]: found };
4849
+ return { choices: { cli: [cli2] }, fields: { path: found.path } };
4850
+ }
4851
+ /**
4852
+ * The answers as they stand, with the CLI setup picked without asking when
4853
+ * the user hasn't picked one: the first ready, or the one already set.
4854
+ */
4855
+ effective(ctx, answers) {
4856
+ if (answers.cli || !ctx.cli) return { ...answers };
4857
+ return { ...answers, cli: { choices: { cli: [ctx.cli] }, fields: {} } };
4858
+ }
4859
+ /** Set in the environment, which wins over setup: shown as it stands, never asked. */
4860
+ locked(question, sources) {
4861
+ return question.keys.length > 0 && question.keys.every((key) => (sources.process[key] ?? "").trim() !== "");
4862
+ }
4863
+ /**
4864
+ * The settings these answers add up to, in question order, less what the
4865
+ * environment sets: that can't be saved over, and wins anyway.
4866
+ */
4867
+ settingsOf(answers, ctx) {
4868
+ const sources = this.deps.readSources();
4869
+ const values = {};
4870
+ const effective = this.effective(ctx, answers);
4871
+ for (const question of this.questions(ctx)) {
4872
+ const chosen = this.locked(question, sources) ? null : given(effective[question.id]);
4873
+ if (chosen) Object.assign(values, question.settings?.(chosen, ctx) ?? {});
4874
+ }
4875
+ for (const key of Object.keys(values)) if ((sources.process[key] ?? "").trim() !== "") delete values[key];
4876
+ return values;
4877
+ }
4878
+ questions(ctx) {
4879
+ return QUESTIONS.filter((question) => !question.applies || question.applies(ctx));
4880
+ }
4881
+ /**
4882
+ * Everything a question may read. The CLI and the profile are the answers
4883
+ * when given, else what is in effect, else the card's own pick, so the
4884
+ * questions that follow from them are counted from the start.
4885
+ *
4886
+ * Calendar.app's calendars only as already listed, unless `listCalendars`:
4887
+ * the helper may ask for calendar access, so it is launched only from a
4888
+ * click, never from a state read, which any GET makes.
4889
+ */
4890
+ async context(opts = {}) {
4891
+ const sources = this.deps.readSources();
4892
+ const env = layerEnv(sources);
4893
+ const current = {};
4894
+ for (const def of SETTINGS) {
4895
+ const value = (env[def.key] ?? "").trim();
4896
+ if (value) current[def.key] = value;
4897
+ }
4898
+ const setup = opts.setup ?? await this.deps.setup();
4899
+ const answers = opts.answers ?? this.file?.answers ?? {};
4900
+ const profileAnswer = given(answers.profile)?.choices.profile?.[0];
4901
+ const profile2 = PROFILES.includes(profileAnswer) ? profileAnswer : current.DAILY_FOCUS_PROFILE === "personal" ? "personal" : "work";
4902
+ const cliAnswer = given(answers.cli);
4903
+ const typed = cliAnswer?.fields.path?.trim();
4904
+ const answered = cliAnswer?.choices.cli?.[0];
4905
+ let clis = this.detected;
4906
+ if (clis && typed && answered && CLI_NAMES.includes(answered) && clis[answered].path !== typed) {
4907
+ clis = { ...clis, [answered]: { ...clis[answered], path: typed, onPath: false, ready: true, problem: null, fix: null } };
4908
+ }
4909
+ const set = current.DAILY_FOCUS_AGENT;
4910
+ const cliLocked = (sources.process.DAILY_FOCUS_AGENT ?? "").trim() !== "";
4911
+ const cli2 = answers.cli ? answered ?? null : cliLocked ? set && CLI_NAMES.includes(set) ? set : null : set && clis?.[set]?.ready ? set : clis ? preferredCli(clis) : null;
4912
+ const started = Object.keys(answers).length > 0;
4913
+ const profileKnown = started && (given(answers.profile) !== null || (sources.process.DAILY_FOCUS_PROFILE ?? "").trim() !== "");
4914
+ let calendars2 = null;
4915
+ const liveAgenda = !["off", "false", "0", "no"].includes((current.DAILY_FOCUS_CALENDAR ?? "").toLowerCase());
4916
+ if (setup.calendar.supported && setup.calendar.built && liveAgenda) {
4917
+ if (opts.listCalendars === true && profileKnown) {
4918
+ this.calendars ??= this.deps.listCalendars().catch((err) => err instanceof Error ? err.message : String(err)).then((listed2) => {
4919
+ this.listed = listed2;
4920
+ });
4921
+ await this.calendars;
4922
+ }
4923
+ calendars2 = this.listed;
4924
+ }
4925
+ const proposals = {};
4926
+ for (const [id, record] of Object.entries(this.file?.proposals ?? {})) {
4927
+ if (record.cli === cli2 && record.profile === profile2 && record.result !== null) proposals[id] = record.result;
4928
+ }
4929
+ return {
4930
+ machine: machineName(),
4931
+ profile: profile2,
4932
+ cli: cli2 && CLI_NAMES.includes(cli2) ? cli2 : null,
4933
+ clis,
4934
+ identity: started ? await this.deps.identity() : { name: null, login: null, logins: [], email: null },
4935
+ calendars: calendars2,
4936
+ calendar: setup.calendar,
4937
+ boards: await this.deps.boards(),
4938
+ current,
4939
+ proposals
4940
+ };
4941
+ }
4942
+ /* ---------- the file ---------- */
4943
+ async load() {
4944
+ if (this.loaded) return;
4945
+ this.loaded = true;
4946
+ try {
4947
+ this.file = parseFile(await readFile8(this.config.onboardingFile, "utf8"));
4948
+ } catch {
4949
+ this.file = null;
4950
+ }
4951
+ }
4952
+ /**
4953
+ * Write the file as it is now. One write at a time: sessions settle side by
4954
+ * side, and two renames of the same temporary file would trip over each other.
4955
+ */
4956
+ save() {
4957
+ const write = this.writes.then(async () => {
4958
+ if (!this.file) return;
4959
+ await mkdir2(this.config.dataDir, { recursive: true });
4960
+ await writeJsonAtomic(this.config.onboardingFile, this.file);
4961
+ });
4962
+ this.writes = write.catch(() => {
4963
+ });
4964
+ return write;
4965
+ }
4966
+ refuse(error) {
4967
+ this.finishError = error;
4968
+ this.deps.onChange();
4969
+ return { error };
4970
+ }
4971
+ };
4972
+ function stripFence(text) {
4973
+ const fenced = /^```(?:json)?\s*\n([\s\S]*?)\n```\s*$/.exec(text.trim());
4974
+ return fenced ? fenced[1] : text.trim();
3552
4975
  }
3553
4976
 
3554
4977
  // src/shellpath.ts
3555
- import { spawn as spawn2 } from "node:child_process";
4978
+ import { spawn as spawn3 } from "node:child_process";
3556
4979
  import { userInfo } from "node:os";
3557
4980
  var START = "__DAILY_FOCUS_PATH_START__";
3558
4981
  var END = "__DAILY_FOCUS_PATH_END__";
3559
4982
  var SHELL_TIMEOUT_MS = 5e3;
3560
4983
  var defaultExec = (file, args, { timeout }) => new Promise((resolve2, reject) => {
3561
- const child = spawn2(file, args, { detached: true, stdio: ["ignore", "pipe", "ignore"] });
4984
+ const child = spawn3(file, args, { detached: true, stdio: ["ignore", "pipe", "ignore"] });
3562
4985
  let output = "";
3563
4986
  let settled = false;
3564
4987
  const finish = (error) => {
@@ -3650,7 +5073,7 @@ async function hydratePath(env = process.env, platform = process.platform, exec
3650
5073
 
3651
5074
  // src/server.ts
3652
5075
  var PUBLIC_DIR = resolve(fileURLToPath(new URL("../public", import.meta.url)));
3653
- var VERSION = packageVersion();
5076
+ var VERSION2 = packageVersion();
3654
5077
  var MIME = {
3655
5078
  ".html": "text/html; charset=utf-8",
3656
5079
  ".js": "text/javascript; charset=utf-8",
@@ -3693,7 +5116,7 @@ async function readBody(req) {
3693
5116
  }
3694
5117
  async function serveStatic(res, urlPath) {
3695
5118
  const relative = normalize(decodeURIComponent(urlPath === "/" ? "/index.html" : urlPath));
3696
- const filePath = join4(PUBLIC_DIR, relative);
5119
+ const filePath = join6(PUBLIC_DIR, relative);
3697
5120
  if (filePath !== PUBLIC_DIR && !filePath.startsWith(PUBLIC_DIR + sep)) {
3698
5121
  res.writeHead(403).end("Forbidden");
3699
5122
  return;
@@ -3743,7 +5166,7 @@ function configure(sources) {
3743
5166
  function statusOf(state, config, busy) {
3744
5167
  const last = state.agentRun.last;
3745
5168
  return {
3746
- version: VERSION,
5169
+ version: VERSION2,
3747
5170
  dataDir: config.dataDir,
3748
5171
  profile: config.profile,
3749
5172
  setupNeeded: state.setup.needed,
@@ -3782,7 +5205,7 @@ async function startServer(env, options = {}) {
3782
5205
  let restart = null;
3783
5206
  let restarting = false;
3784
5207
  let settingsWrites = Promise.resolve();
3785
- await stat4(join4(PUBLIC_DIR, "app.js")).catch(() => {
5208
+ await stat4(join6(PUBLIC_DIR, "app.js")).catch(() => {
3786
5209
  console.warn(
3787
5210
  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)"
3788
5211
  );
@@ -3822,6 +5245,46 @@ async function startServer(env, options = {}) {
3822
5245
  restartWhenIdle();
3823
5246
  }
3824
5247
  });
5248
+ const onboarding = new Onboarding(config, {
5249
+ onChange: () => {
5250
+ void broadcast();
5251
+ restartWhenIdle();
5252
+ },
5253
+ // A path already set for either CLI is looked at before PATH.
5254
+ detectClis: () => {
5255
+ const configured = {};
5256
+ if (config.agent.cli && config.agent.binPath !== config.agent.cli) configured[config.agent.cli] = config.agent.binPath;
5257
+ if (config.assistant.agent && config.assistant.binPath !== config.assistant.agent) configured[config.assistant.agent] = config.assistant.binPath;
5258
+ return detectClis(configured);
5259
+ },
5260
+ probeCli: (path) => probeCli(path, null, false),
5261
+ readSources,
5262
+ setup: () => setupState(config),
5263
+ identity: () => detectIdentity(config),
5264
+ boards: async () => {
5265
+ const actions = await store.readActions();
5266
+ const now = /* @__PURE__ */ new Date();
5267
+ const health = (view) => ({
5268
+ enabled: view.enabled,
5269
+ fetchedAt: view.fetchedAt,
5270
+ reason: view.reason?.message ?? null
5271
+ });
5272
+ return { github: health(board.view(actions, now)), jira: health(tickets.view(actions, now)) };
5273
+ },
5274
+ listCalendars: async () => {
5275
+ const facts = await runHelper(config.calendar.appPath, []);
5276
+ const today = /* @__PURE__ */ new Map();
5277
+ for (const event of facts.events) {
5278
+ if (typeof event.calendarId === "string") today.set(event.calendarId, (today.get(event.calendarId) ?? 0) + 1);
5279
+ }
5280
+ return facts.calendars.map(({ id, title, source }) => ({ title, source, eventsToday: today.get(id) ?? 0 }));
5281
+ },
5282
+ checkSettings: (values) => {
5283
+ const applied = applySettingsChange(readSources(), values);
5284
+ return "error" in applied ? applied.error : null;
5285
+ },
5286
+ saveSettings: (values) => saveSettingsValues(values)
5287
+ });
3825
5288
  let codexWarnings = [];
3826
5289
  const codexBins = /* @__PURE__ */ new Set();
3827
5290
  if (config.agent.cli === "codex") codexBins.add(config.agent.binPath);
@@ -3831,12 +5294,34 @@ async function startServer(env, options = {}) {
3831
5294
  for (const warning of codexWarnings) console.warn(`[daily-focus] ${warning}`);
3832
5295
  if (codexWarnings.length > 0) void broadcast();
3833
5296
  });
5297
+ async function startRequestedBrief() {
5298
+ if (!agent.enabled || !await firstBriefPending(config) || !await onboarding.takeBriefRequest()) return;
5299
+ await agent.run("hand").catch((err) => {
5300
+ console.error(`[daily-focus] could not start the first brief: ${err.message}`);
5301
+ });
5302
+ }
5303
+ function saveSettingsValues(values) {
5304
+ const saved = settingsWrites.then(async () => {
5305
+ const sources = readSources();
5306
+ const applied = applySettingsChange(sources, values);
5307
+ if ("error" in applied) return applied.error;
5308
+ const changed = JSON.stringify(Object.entries(applied.settings).sort()) !== JSON.stringify(Object.entries(sources.settings).sort());
5309
+ if (changed) {
5310
+ await saveSettings(sources.settingsPath, applied.settings);
5311
+ requestRestart();
5312
+ }
5313
+ return null;
5314
+ });
5315
+ settingsWrites = saved.catch(() => {
5316
+ });
5317
+ return saved;
5318
+ }
3834
5319
  function requestRestart() {
3835
5320
  restart = options.onRestart ? "waiting" : "manual";
3836
5321
  restartWhenIdle();
3837
5322
  }
3838
5323
  function restartWhenIdle() {
3839
- if (restart !== "waiting" || restarting || agent.busy || assistant.busy) return;
5324
+ if (restart !== "waiting" || restarting || agent.busy || assistant.busy || onboarding.busy) return;
3840
5325
  restarting = true;
3841
5326
  setImmediate(() => options.onRestart?.());
3842
5327
  }
@@ -3848,11 +5333,9 @@ async function startServer(env, options = {}) {
3848
5333
  // A link that can't be read is no reason to withhold the page.
3849
5334
  storeLinkWarning(config).catch(() => null)
3850
5335
  ]);
3851
- const sources = readSources();
3852
- const setup = await setupState(config, {
3853
- profileChosen: Boolean(layerEnv(sources).DAILY_FOCUS_PROFILE?.trim())
3854
- });
5336
+ const setup = await setupState(config);
3855
5337
  const pulls = board.view(actions, now);
5338
+ const onboardingState = await onboarding.view(setup);
3856
5339
  return {
3857
5340
  ...state,
3858
5341
  board: pulls,
@@ -3862,6 +5345,7 @@ async function startServer(env, options = {}) {
3862
5345
  setupWarnings: [...configWarnings, ...codexWarnings, ...linkWarning ? [linkWarning] : []],
3863
5346
  restart,
3864
5347
  setup,
5348
+ onboarding: onboardingState,
3865
5349
  assetVersion,
3866
5350
  storeId
3867
5351
  };
@@ -3907,7 +5391,13 @@ data: ${payload}
3907
5391
  basename(tempPathFor(config.timeZoneFile)),
3908
5392
  // Appended by this process, which broadcasts on its own.
3909
5393
  basename(config.assistantLogFile),
3910
- basename(config.agentLogFile)
5394
+ basename(config.agentLogFile),
5395
+ // Rewritten by the runner before every run; nothing on the page shows it.
5396
+ basename(config.agentSettingsFile),
5397
+ basename(tempPathFor(config.agentSettingsFile)),
5398
+ // Rewritten by the setup screen, which broadcasts on its own.
5399
+ basename(config.onboardingFile),
5400
+ basename(tempPathFor(config.onboardingFile))
3911
5401
  ]
3912
5402
  }
3913
5403
  );
@@ -3920,6 +5410,8 @@ data: ${payload}
3920
5410
  });
3921
5411
  await assistant.start();
3922
5412
  await agent.start();
5413
+ await onboarding.start(await firstBriefPending(config));
5414
+ await startRequestedBrief();
3923
5415
  let ticks = Promise.resolve();
3924
5416
  function clockTick() {
3925
5417
  ticks = ticks.then(tickOnce).catch((err) => {
@@ -4218,6 +5710,40 @@ data: ${JSON.stringify(await buildState())}
4218
5710
  void broadcast(state);
4219
5711
  return;
4220
5712
  }
5713
+ if (path.startsWith("/api/onboarding/") && req.method === "POST") {
5714
+ const body = await readJsonObject(req, res);
5715
+ if (!body) return;
5716
+ const step = typeof body.step === "string" ? body.step : "";
5717
+ let refused2 = null;
5718
+ if (path === "/api/onboarding/answer") {
5719
+ refused2 = await onboarding.answer(step, body.answer);
5720
+ } else if (path === "/api/onboarding/revise") {
5721
+ refused2 = typeof body.text === "string" && body.text.trim() !== "" ? await onboarding.revise(step, body.text, body.draft) : '"text" is required: what to change';
5722
+ } else if (path === "/api/onboarding/retry") {
5723
+ refused2 = await onboarding.retry(step);
5724
+ } else if (path === "/api/onboarding/detect") {
5725
+ await onboarding.detect();
5726
+ } else if (path === "/api/onboarding/finish") {
5727
+ const finished = await onboarding.finish();
5728
+ if ("error" in finished) refused2 = finished.error;
5729
+ else if (!finished.restart) await startRequestedBrief();
5730
+ } else {
5731
+ sendJSON(res, 404, { error: `no such setup action: ${path}` });
5732
+ return;
5733
+ }
5734
+ if (refused2) {
5735
+ sendJSON(res, 409, { error: refused2 });
5736
+ return;
5737
+ }
5738
+ const state = await buildState();
5739
+ sendState(res, state);
5740
+ void broadcast(state);
5741
+ return;
5742
+ }
5743
+ if (path === "/api/onboarding/sources" && req.method === "GET") {
5744
+ sendJSON(res, 200, await onboarding.previewSources());
5745
+ return;
5746
+ }
4221
5747
  if (path === "/api/settings" && req.method === "GET") {
4222
5748
  sendJSON(res, 200, { ...describeSettings(readSources()), restart });
4223
5749
  return;
@@ -4230,20 +5756,7 @@ data: ${JSON.stringify(await buildState())}
4230
5756
  sendJSON(res, 400, { error: '"values" must be an object of settings' });
4231
5757
  return;
4232
5758
  }
4233
- const saved = settingsWrites.then(async () => {
4234
- const sources = readSources();
4235
- const applied = applySettingsChange(sources, values);
4236
- if ("error" in applied) return applied.error;
4237
- const changed = JSON.stringify(Object.entries(applied.settings).sort()) !== JSON.stringify(Object.entries(sources.settings).sort());
4238
- if (changed) {
4239
- await saveSettings(sources.settingsPath, applied.settings);
4240
- requestRestart();
4241
- }
4242
- return null;
4243
- });
4244
- settingsWrites = saved.catch(() => {
4245
- });
4246
- const refused2 = await saved;
5759
+ const refused2 = await saveSettingsValues(values);
4247
5760
  if (refused2) {
4248
5761
  sendJSON(res, 400, { error: refused2 });
4249
5762
  return;
@@ -4283,8 +5796,8 @@ data: ${JSON.stringify(await buildState())}
4283
5796
  if (path === "/api/focus/objective" && req.method === "POST") {
4284
5797
  const body = await readJsonObject(req, res);
4285
5798
  if (!body) return;
4286
- const { objective, blocker } = body;
4287
- for (const [name, value] of [["objective", objective], ["blocker", blocker]]) {
5799
+ const { objective: objective2, blocker } = body;
5800
+ for (const [name, value] of [["objective", objective2], ["blocker", blocker]]) {
4288
5801
  if (value !== void 0 && value !== null && typeof value !== "string") {
4289
5802
  sendJSON(res, 400, { error: `"${name}" must be text, or null to clear it` });
4290
5803
  return;
@@ -4293,7 +5806,7 @@ data: ${JSON.stringify(await buildState())}
4293
5806
  const current = await readEditable(config.focusFile);
4294
5807
  const next = setFocusFields(
4295
5808
  current.text,
4296
- { objective: objective ?? null, blocker: blocker ?? null },
5809
+ { objective: objective2 ?? null, blocker: blocker ?? null },
4297
5810
  FOCUS_TEMPLATE
4298
5811
  );
4299
5812
  const result = await saveEditable(config.focusFile, next, current.version);
@@ -4310,10 +5823,10 @@ data: ${JSON.stringify(await buildState())}
4310
5823
  try {
4311
5824
  const facts = await runHelper(config.calendar.appPath, []);
4312
5825
  const seen = /* @__PURE__ */ new Set();
4313
- const calendars = facts.calendars.filter((entry) => !seen.has(`${entry.title}
5826
+ const calendars2 = facts.calendars.filter((entry) => !seen.has(`${entry.title}
4314
5827
  ${entry.source}`) && seen.add(`${entry.title}
4315
5828
  ${entry.source}`)).map(({ title, source }) => ({ title, source })).sort((a, b) => a.title.localeCompare(b.title));
4316
- sendJSON(res, 200, { calendars });
5829
+ sendJSON(res, 200, { calendars: calendars2 });
4317
5830
  } catch (err) {
4318
5831
  sendJSON(res, 409, { error: err.message });
4319
5832
  }
@@ -4352,6 +5865,7 @@ ${entry.source}`)).map(({ title, source }) => ({ title, source })).sort((a, b) =
4352
5865
  tickets.stop();
4353
5866
  await assistant.close();
4354
5867
  await agent.close();
5868
+ await onboarding.close();
4355
5869
  stopWatching();
4356
5870
  stopWatchingAssets();
4357
5871
  for (const res of subscribers) res.end();