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