daily-focus 1.1.1-dev.2 → 1.2.0-dev.4

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 CHANGED
@@ -131,11 +131,13 @@ notes live too; the reply streams in there, and you can follow up in the same
131
131
  conversation. The panel stays on the item it was opened for while you move through
132
132
  the list.
133
133
 
134
- It reads and drafts, and that is all. An email reply is created as a Gmail draft on
135
- the thread, so it is waiting there when you open the mail; a reply to a reviewer is
136
- handed back as text for you to post. It never sends, posts, or edits a file, and it
137
- has no checkout: fixing conflicts or a build is work for a real coding session, and
138
- this is for the questions you would otherwise answer by opening six tabs.
134
+ It is told to read and draft, and nothing more. An email reply is created as a Gmail
135
+ draft on the thread, so it is waiting there when you open the mail; a reply to a
136
+ reviewer is handed back as text for you to post. It is told never to send, post, or
137
+ edit a file, and it has no checkout: fixing conflicts or a build is work for a real
138
+ coding session, and this is for the questions you would otherwise answer by opening
139
+ six tabs. How much of that the CLI itself enforces differs between Claude Code and
140
+ Codex; see [What the assistant can do](SETUP.md#what-the-assistant-can-do).
139
141
 
140
142
  ## The morning agent
141
143
 
package/SETUP.md CHANGED
@@ -98,7 +98,7 @@ under **Morning agent** in the settings. In `.env` or the environment that is:
98
98
  DAILY_FOCUS_AGENT=codex
99
99
  ```
100
100
 
101
- Each scheduled morning at 07:00 local time the dashboard starts the agent through
101
+ Each scheduled morning at 07:00 your time the dashboard starts the agent through
102
102
  that CLI, headless, in the store, and the brief appears when it finishes. Set
103
103
  `DAILY_FOCUS_AGENT_AT` to another time, such as `06:30`. The dashboard has to be
104
104
  running at the time; if it wasn't, it runs the agent as soon as it is started that
@@ -110,6 +110,15 @@ otherwise; see [Match your working week](#match-your-working-week).
110
110
  refresh icon still does. A new store's first brief always comes from the setup
111
111
  steps' button, so the clock never runs the agent before you've told it where to look.
112
112
 
113
+ "Your time" is the time zone of the browser you last looked at the dashboard
114
+ in. The page tells the dashboard when it loads, reconnects or comes back into view,
115
+ and when the browser's own zone changes, so a dashboard on a Mac at home follows
116
+ you abroad as soon as you open it there. The dashboard also follows the Mac it runs
117
+ on when that Mac changes time zones, which covers the morning after a flight before
118
+ you have opened it. Whichever changed last wins, and the dashboard remembers it
119
+ across restarts. Every date and time on the page is shown in that zone; a browser
120
+ somewhere else says so under the page title.
121
+
113
122
  Whichever way it starts, every run lands in the same place: the report it gives when
114
123
  it finishes, and a chat where you can ask it why it did what it did. See
115
124
  [The morning agent's runs](#the-morning-agents-runs).
@@ -117,14 +126,23 @@ it finishes, and a chat where you can ask it why it did what it did. See
117
126
  The agent reaches your sources the way the CLI does:
118
127
 
119
128
  - **Codex** runs in its workspace-write sandbox with network access, confined to the
120
- store. Connectors from the Codex app are found through its plugin catalogue. There
121
- is nobody to approve a command outside the sandbox, so the agent reports that
122
- restriction for any `gh` command that needs it, such as switching accounts.
129
+ store. Connectors from the Codex app are found through its plugin catalogue, and
130
+ every write through one is refused: a connector tool counts as a write unless it
131
+ is marked read-only. A command that needs to leave the sandbox, such as `gh`
132
+ switching accounts, is decided by your own Codex approval settings, as it would be
133
+ in your Codex. With nobody set to approve, the agent reports that restriction; with
134
+ Codex's automatic reviewer, the reviewer decides. The dashboard won't start a run
135
+ on a Codex older than 0.144.0, which would ignore the refusal of connector writes;
136
+ update Codex if a run fails saying so.
123
137
  - **Claude Code** may write `items.json` and nothing else in the store, and reach
124
138
  its sources with only what `DAILY_FOCUS_AGENT_TOOLS` allows. The default covers
125
139
  GitHub, Jira, web pages and the claude.ai Gmail connector. Add your calendar, chat
126
140
  and document connectors to the list by name, or the brief will report them as
127
- unreachable.
141
+ unreachable. The default allows every Gmail tool, sending included, and `gh` and
142
+ `acli` can write as well as read; the agent's instructions forbid changing
143
+ anything. To narrow Gmail to reading, name its tools instead of the connector, as
144
+ [What the assistant can do](#what-the-assistant-can-do) shows, without the two
145
+ draft tools.
128
146
 
129
147
  Set `DAILY_FOCUS_AGENT_MODEL` and `DAILY_FOCUS_AGENT_EFFORT` if the CLI's own
130
148
  defaults are not what you want for a brief; a brief is worth a high effort. If the
@@ -268,14 +286,53 @@ the CLI is configured with; `DAILY_FOCUS_ASSISTANT_MODEL` and
268
286
  `DAILY_FOCUS_ASSISTANT_EFFORT` override them. If the server can't find the CLI,
269
287
  set `DAILY_FOCUS_ASSISTANT_BIN` to its path.
270
288
 
271
- The assistant reads and drafts; it never sends, posts or edits anything, and it has
272
- no checkout to work in. That is deliberate. Fixing merge conflicts or a failing build
273
- belongs in a real coding session; this panel is for the questions you'd otherwise
274
- answer by opening six tabs. The Gmail draft is the one thing it creates, through the
275
- Gmail tool the CLI has: with Claude Code that is the claude.ai Gmail connector, which
276
- `DAILY_FOCUS_ASSISTANT_TOOLS` allows by default; with Codex it is the Gmail plugin
277
- from the Codex app, which the CLI finds through its plugin catalogue. Without either,
278
- the assistant hands you the text instead and says so.
289
+ The assistant is told to read and draft, and never to send, post or edit anything,
290
+ and it has no checkout to work in. That is deliberate. Fixing merge conflicts or a
291
+ failing build belongs in a real coding session; this panel is for the questions you'd
292
+ otherwise answer by opening six tabs. The Gmail draft is the one thing it creates,
293
+ and revises when you ask, through the Gmail tool the CLI has: with Claude Code that
294
+ is the claude.ai Gmail connector, which `DAILY_FOCUS_ASSISTANT_TOOLS` allows by
295
+ default; with Codex it is the Gmail app linked to your ChatGPT account, which the CLI
296
+ finds through its plugin catalogue. Without either, the assistant hands you the text
297
+ instead and says so.
298
+
299
+ ### What the assistant can do
300
+
301
+ The assistant runs the CLI you already use, with the access that CLI already has.
302
+ The dashboard narrows some of that, and leaves the rest to the assistant's
303
+ instructions. What the CLI itself enforces:
304
+
305
+ - **Claude Code** may use only the tools in `DAILY_FOCUS_ASSISTANT_TOOLS`, and never
306
+ its file-editing tools, whatever the list says; anything else is refused rather
307
+ than asked about. Rules in your own Claude Code settings that allow a tool still
308
+ apply. The default list is broader than the assistant needs:
309
+ `mcp__claude_ai_Gmail` allows every tool on the Gmail connector, sending and
310
+ deleting included, and `gh` and `acli` can comment, merge and move tickets as well
311
+ as read. Only the instructions keep it from doing those. To hold it to reading
312
+ mail and drafting replies, name the Gmail tools instead of the connector:
313
+
314
+ ```dotenv
315
+ DAILY_FOCUS_ASSISTANT_TOOLS=Bash(gh *),Bash(acli *),WebFetch,mcp__claude_ai_Gmail__search_threads,mcp__claude_ai_Gmail__get_thread,mcp__claude_ai_Gmail__get_message,mcp__claude_ai_Gmail__list_labels,mcp__claude_ai_Gmail__list_drafts,mcp__claude_ai_Gmail__get_draft,mcp__claude_ai_Gmail__create_draft,mcp__claude_ai_Gmail__update_draft
316
+ ```
317
+
318
+ A tool left off the list is refused, so if the connector renames one, the
319
+ assistant reports that it can't reach your mail rather than working around it.
320
+ - **Codex** refuses every write through a connector (Gmail, Calendar, Drive, GitHub
321
+ and the other apps your ChatGPT account links) except creating and revising a
322
+ Gmail draft. A connector tool counts as a write unless it is marked read-only. A
323
+ tool you have told Codex to always allow, or an app given approval settings of its
324
+ own in your Codex config, keeps them. Computer use, the plugins that act in your
325
+ apps and browser with your sign-ins, is off unless you turn on
326
+ `DAILY_FOCUS_ASSISTANT_COMPUTER_USE`; the dashboard checks with Codex that it is
327
+ off before each session, and refuses to start one it can't confirm. Shell
328
+ commands follow your own Codex config: the sandbox keeps writes in the
329
+ assistant's working directory, and a command that needs to leave it is decided
330
+ by your Codex approval settings, as it would be in your own Codex. That keeps
331
+ `gh` working, and means it can write. All of this needs Codex 0.149.0 or
332
+ newer, and the page warns when yours is older. Before 0.144.0 Codex silently
333
+ ignores the refusal of connector writes, so the dashboard won't start the
334
+ assistant or the morning agent on it at all; before 0.149.0 it can't switch
335
+ computer use off, so the assistant won't answer while that setting is off.
279
336
 
280
337
  Its instructions live in `prompts/assistant.md`, linked into the store as
281
338
  `assistant.md` each time the dashboard starts. What it says stays in the chat in the panel: it
@@ -484,13 +541,14 @@ matters when `DAILY_FOCUS_FREE_WINDOWS` is off.
484
541
  | `DAILY_FOCUS_ASSISTANT_BIN` | *the CLI's name* | Path to the assistant's CLI, for when the server's PATH lacks it. `~` is expanded |
485
542
  | `DAILY_FOCUS_ASSISTANT_MODEL` | *the CLI's own* | Model passed to the CLI untouched: an alias like `opus`, or a full id. Unset passes no flag |
486
543
  | `DAILY_FOCUS_ASSISTANT_EFFORT` | *the CLI's own* | Effort passed to the CLI untouched: `low`, `medium`, `high`, `xhigh` or `max`. Unset passes no flag |
487
- | `DAILY_FOCUS_ASSISTANT_TOOLS` | `Bash(gh *),Bash(acli *),WebFetch,mcp__claude_ai_Gmail` | What Claude Code may use without asking, in its permission syntax, comma-separated. Editing tools are denied regardless. Ignored by Codex |
544
+ | `DAILY_FOCUS_ASSISTANT_TOOLS` | `Bash(gh *),Bash(acli *),WebFetch,mcp__claude_ai_Gmail` | What Claude Code may use without asking, in its permission syntax, comma-separated. Editing tools are denied regardless. The default allows every Gmail tool, sending included; see [What the assistant can do](#what-the-assistant-can-do) to narrow it. Ignored by Codex |
545
+ | `DAILY_FOCUS_ASSISTANT_COMPUTER_USE` | `off` | `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. Ignored by Claude Code, which uses only the tools in its list |
488
546
  | `DAILY_FOCUS_AGENT` | `off` | `codex` or `claude` lets the dashboard run the morning agent through that CLI: each scheduled morning, and from the refresh icon beside the brief's age. `off` hides it |
489
547
  | `DAILY_FOCUS_AGENT_AT` | `07:00` | Local time, `HH:MM`, at which the dashboard starts the agent on each scheduled day, or as soon as it is running after that. `off` means it never starts one on its own; the refresh icon still does. Needs `DAILY_FOCUS_AGENT` |
490
548
  | `DAILY_FOCUS_AGENT_BIN` | *the CLI's name* | Path to that CLI, for when the server's PATH lacks it. `~` is expanded |
491
549
  | `DAILY_FOCUS_AGENT_MODEL` | *the CLI's own* | Model passed to the CLI untouched. Unset passes no flag |
492
550
  | `DAILY_FOCUS_AGENT_EFFORT` | *the CLI's own* | Effort passed to the CLI untouched. Unset passes no flag |
493
- | `DAILY_FOCUS_AGENT_TOOLS` | `Bash(gh *),Bash(acli *),WebFetch,mcp__claude_ai_Gmail` | What Claude Code may use to reach your sources, in its permission syntax, comma-separated. Writing `items.json`, and nothing else in the store, is allowed regardless. Ignored by Codex |
551
+ | `DAILY_FOCUS_AGENT_TOOLS` | `Bash(gh *),Bash(acli *),WebFetch,mcp__claude_ai_Gmail` | What Claude Code may use to reach your sources, in its permission syntax, comma-separated. Writing `items.json`, and nothing else in the store, is allowed regardless. The default allows every Gmail tool, sending included. Ignored by Codex |
494
552
 
495
553
  ## If something looks wrong
496
554
 
@@ -1,3 +1,7 @@
1
+ import {
2
+ readSystemZone,
3
+ startingZone
4
+ } from "./chunk-AIQO2YAI.js";
1
5
  import {
2
6
  linkState
3
7
  } from "./chunk-QKT2FAB5.js";
@@ -8,7 +12,7 @@ import {
8
12
  readArchiveIndex,
9
13
  resolveItems,
10
14
  runContractChecks
11
- } from "./chunk-UM2EPNWR.js";
15
+ } from "./chunk-26JG3D5C.js";
12
16
  import {
13
17
  BRIEF_REFRESH_GRACE_HOURS,
14
18
  describeSchedule,
@@ -18,7 +22,7 @@ import {
18
22
  resolveSchedule,
19
23
  runsOn,
20
24
  workingMsBetween
21
- } from "./chunk-OPASVYLR.js";
25
+ } from "./chunk-RNE27FQY.js";
22
26
  import {
23
27
  command
24
28
  } from "./chunk-BC2W7NND.js";
@@ -27,6 +31,7 @@ import {
27
31
  import { readFile, readdir } from "node:fs/promises";
28
32
  import { resolve } from "node:path";
29
33
  var config = loadConfig();
34
+ process.env.TZ = (await startingZone(config.timeZoneFile, await readSystemZone())).timeZone;
30
35
  var store = new Store(config);
31
36
  var failures = 0;
32
37
  var warnings = 0;
@@ -14,7 +14,7 @@ import {
14
14
  startOfLocalDay,
15
15
  workingDaysSince,
16
16
  workingMsBetween
17
- } from "./chunk-OPASVYLR.js";
17
+ } from "./chunk-RNE27FQY.js";
18
18
 
19
19
  // src/fs.ts
20
20
  import { mkdir, rename, writeFile } from "node:fs/promises";
@@ -0,0 +1,107 @@
1
+ import {
2
+ writeJsonAtomic
3
+ } from "./chunk-26JG3D5C.js";
4
+ import {
5
+ localTimeZone
6
+ } from "./chunk-RNE27FQY.js";
7
+
8
+ // src/zone.ts
9
+ import { readFile, readlink } from "node:fs/promises";
10
+ function validZone(raw) {
11
+ if (typeof raw !== "string" || !/^[A-Za-z][A-Za-z0-9_+\-/]*$/.test(raw)) return null;
12
+ try {
13
+ new Intl.DateTimeFormat("en", { timeZone: raw });
14
+ return raw;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+ async function readSystemZone(path = "/etc/localtime") {
20
+ try {
21
+ const target = await readlink(path);
22
+ const name = /zoneinfo\/(?:posix\/|right\/)?(.+)$/.exec(target)?.[1];
23
+ return validZone(name);
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+ async function startingZone(file, system) {
29
+ const saved = await loadZoneFile(file);
30
+ if (!saved) return { timeZone: localTimeZone(), moved: false };
31
+ if (system && saved.systemTimeZone && system !== saved.systemTimeZone) return { timeZone: system, moved: true };
32
+ return { timeZone: saved.timeZone, moved: false };
33
+ }
34
+ async function loadZoneFile(file) {
35
+ let parsed;
36
+ try {
37
+ parsed = JSON.parse(await readFile(file, "utf8"));
38
+ } catch {
39
+ return null;
40
+ }
41
+ const record = parsed ?? {};
42
+ const timeZone = validZone(record.timeZone);
43
+ if (!timeZone) return null;
44
+ return { timeZone, systemTimeZone: validZone(record.systemTimeZone) };
45
+ }
46
+ var TimeZoneKeeper = class {
47
+ #file;
48
+ #readSystem;
49
+ #current = localTimeZone();
50
+ /** The Mac's zone as last read, so `checkSystem` acts on a change rather than a difference. */
51
+ #system = null;
52
+ constructor(file, readSystem = readSystemZone) {
53
+ this.#file = file;
54
+ this.#readSystem = readSystem;
55
+ }
56
+ get current() {
57
+ return this.#current;
58
+ }
59
+ /** Decide the zone at startup; see `startingZone`. */
60
+ async start() {
61
+ this.#system = await this.#readSystem();
62
+ const { timeZone, moved } = await startingZone(this.#file, this.#system);
63
+ if (moved) await this.#adopt(timeZone);
64
+ else this.#apply(timeZone);
65
+ }
66
+ /** A browser says it is in `zone`. True if that changed the dashboard's. */
67
+ async report(zone) {
68
+ if (zone === this.#current) return false;
69
+ await this.#adopt(zone);
70
+ return true;
71
+ }
72
+ /** Follow the Mac if its zone has changed since last read. True if that changed the dashboard's. */
73
+ async checkSystem() {
74
+ const system = await this.#readSystem();
75
+ if (!system || system === this.#system) return false;
76
+ this.#system = system;
77
+ if (system === this.#current) {
78
+ await this.#save();
79
+ return false;
80
+ }
81
+ await this.#adopt(system);
82
+ return true;
83
+ }
84
+ async #adopt(zone) {
85
+ this.#apply(zone);
86
+ await this.#save();
87
+ }
88
+ #apply(zone) {
89
+ this.#current = zone;
90
+ process.env.TZ = zone;
91
+ }
92
+ async #save() {
93
+ const file = { timeZone: this.#current, systemTimeZone: this.#system };
94
+ try {
95
+ await writeJsonAtomic(this.#file, file);
96
+ } catch (err) {
97
+ console.warn(`[daily-focus] could not save the time zone: ${err.message}`);
98
+ }
99
+ }
100
+ };
101
+
102
+ export {
103
+ validZone,
104
+ readSystemZone,
105
+ startingZone,
106
+ TimeZoneKeeper
107
+ };
@@ -353,7 +353,8 @@ function envAssistant(env) {
353
353
  binPath: expandHome((env.DAILY_FOCUS_ASSISTANT_BIN ?? "").trim() || (agent ?? "claude")),
354
354
  model: envString("DAILY_FOCUS_ASSISTANT_MODEL", "", env) || null,
355
355
  effort: envString("DAILY_FOCUS_ASSISTANT_EFFORT", "", env) || null,
356
- tools: tools.length > 0 ? tools : DEFAULT_ASSISTANT_TOOLS
356
+ tools: tools.length > 0 ? tools : DEFAULT_ASSISTANT_TOOLS,
357
+ computerUse: envFlag("DAILY_FOCUS_ASSISTANT_COMPUTER_USE", false, env)
357
358
  };
358
359
  }
359
360
  var DEFAULT_ASSISTANT_TOOLS = ["Bash(gh *)", "Bash(acli *)", "WebFetch", "mcp__claude_ai_Gmail"];
@@ -444,6 +445,7 @@ function loadConfig(env = loadEnv()) {
444
445
  pullsFile: resolve2(dataDir, "prs.json"),
445
446
  calendarFile: resolve2(dataDir, "calendar.json"),
446
447
  ticketsFile: resolve2(dataDir, "tickets.json"),
448
+ timeZoneFile: resolve2(dataDir, "timezone.json"),
447
449
  // The same 1 to 120 the session endpoint holds a start to.
448
450
  sessionMinutes: envBounded("DAILY_FOCUS_SESSION_MINUTES", 25, 1, 120, env),
449
451
  awayAfterMinutes: envBounded("DAILY_FOCUS_AWAY_AFTER", profile === "personal" ? 0 : 10, 0, 24 * 60, env),
@@ -1,3 +1,105 @@
1
+ // src/codex.ts
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ var GMAIL_CONNECTOR = "connector_2128aebfecb84f64a069897515042a44";
5
+ var CONNECTOR_POLICY = [
6
+ 'apps._default.default_tools_approval_mode="writes"',
7
+ 'apps._default.approvals_reviewer="user"'
8
+ ];
9
+ var GMAIL_DRAFTS = ["create_draft", "update_draft"].map(
10
+ (tool) => `apps.${GMAIL_CONNECTOR}.tools.${tool}.approval_mode="approve"`
11
+ );
12
+ var COMPUTER_USE_PLUGINS = [
13
+ "unified-computer-use@openai-bundled",
14
+ "computer-use@openai-bundled",
15
+ "browser@openai-bundled",
16
+ "chrome@openai-bundled"
17
+ ];
18
+ var COMPUTER_USE_SERVERS = ["node_repl", "cua_repl", "computer-use"];
19
+ var LIST_TIMEOUT_MS = 3e4;
20
+ var exec = promisify(execFile);
21
+ var defaultExec = async (file, args, { timeout }) => {
22
+ try {
23
+ const { stdout } = await exec(file, args, { timeout, maxBuffer: 16 * 1024 * 1024 });
24
+ return stdout;
25
+ } catch (err) {
26
+ const stderr = String(err.stderr ?? "").trim();
27
+ if (!stderr) throw err;
28
+ throw new Error(stderr.split("\n").slice(0, 2).join(" "));
29
+ }
30
+ };
31
+ function configArgs(overrides) {
32
+ return overrides.flatMap((override) => ["-c", override]);
33
+ }
34
+ async function enabledServers(bin, overrides, run) {
35
+ const output = await run(bin, ["mcp", "list", "--json", ...configArgs(overrides)], { timeout: LIST_TIMEOUT_MS });
36
+ let servers;
37
+ try {
38
+ servers = JSON.parse(output);
39
+ } catch {
40
+ throw new Error("codex mcp list --json printed something other than JSON");
41
+ }
42
+ if (!Array.isArray(servers)) throw new Error("codex mcp list --json printed something other than a list");
43
+ return servers.filter((server) => typeof server?.name === "string").filter((server) => server.enabled !== false).map((server) => server.name);
44
+ }
45
+ async function computerUseOff(bin, run = defaultExec) {
46
+ const overrides = COMPUTER_USE_PLUGINS.map((plugin) => `plugins.${plugin}.enabled=false`);
47
+ const left = (await enabledServers(bin, overrides, run)).filter((name) => COMPUTER_USE_SERVERS.includes(name));
48
+ if (left.length === 0) return overrides;
49
+ overrides.push(...left.map((name) => `mcp_servers.${name}.enabled=false`));
50
+ const still = (await enabledServers(bin, overrides, run)).filter((name) => COMPUTER_USE_SERVERS.includes(name));
51
+ if (still.length > 0) throw new Error(`Codex still starts ${still.join(", ")}`);
52
+ return overrides;
53
+ }
54
+ var CODEX_CONNECTOR_POLICY = "0.144.0";
55
+ var CODEX_MINIMUM = "0.149.0";
56
+ function versionParts(text) {
57
+ const match = /(\d+)\.(\d+)\.(\d+)/.exec(text);
58
+ return match ? match.slice(1, 4).map(Number) : null;
59
+ }
60
+ function olderThan(version, than) {
61
+ const a = versionParts(version);
62
+ const b = versionParts(than);
63
+ if (!a || !b) return false;
64
+ for (let i = 0; i < 3; i++) {
65
+ if (a[i] !== b[i]) return a[i] < b[i];
66
+ }
67
+ return false;
68
+ }
69
+ async function codexVersion(bin, run = defaultExec) {
70
+ try {
71
+ return versionParts(await run(bin, ["--version"], { timeout: LIST_TIMEOUT_MS }))?.join(".") ?? null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+ function refusalFor(version) {
77
+ if (!version || !olderThan(version, CODEX_CONNECTOR_POLICY)) return null;
78
+ return `Codex ${version} is older than ${CODEX_CONNECTOR_POLICY}, which ignores the dashboard's refusal of connector writes without saying so, so the dashboard won't run it. Update Codex`;
79
+ }
80
+ var CodexGate = class {
81
+ #bin;
82
+ #run;
83
+ #cleared = false;
84
+ constructor(bin, run = defaultExec) {
85
+ this.#bin = bin;
86
+ this.#run = run;
87
+ }
88
+ async refusal() {
89
+ if (this.#cleared) return null;
90
+ const version = await codexVersion(this.#bin, this.#run);
91
+ const refusal = refusalFor(version);
92
+ if (version && !refusal) this.#cleared = true;
93
+ return refusal;
94
+ }
95
+ };
96
+ async function codexVersionWarning(bin, run = defaultExec) {
97
+ const version = await codexVersion(bin, run);
98
+ if (!version || !olderThan(version, CODEX_MINIMUM)) return null;
99
+ const consequence = olderThan(version, CODEX_CONNECTOR_POLICY) ? `Before ${CODEX_CONNECTOR_POLICY} Codex ignores the dashboard's refusal of connector writes without saying so, so the dashboard won't start the agent or the assistant on it.` : `Before ${CODEX_MINIMUM} Codex can't switch computer use off, so the assistant won't answer while computer use is off in its settings.`;
100
+ return `Codex ${version} is older than ${CODEX_MINIMUM}, the oldest the dashboard's limits on Codex work with. ${consequence} Update Codex.`;
101
+ }
102
+
1
103
  // src/assistant.ts
2
104
  import { spawn } from "node:child_process";
3
105
  import { randomUUID } from "node:crypto";
@@ -166,13 +268,20 @@ var claudeAdapter = {
166
268
  }
167
269
  };
168
270
  var codexAdapter = {
169
- args({ prompt, sessionId, model, effort }) {
271
+ args({ prompt, sessionId, model, effort, drafts, codexConfig }) {
170
272
  const args = ["exec"];
171
273
  if (sessionId) args.push("resume");
172
274
  args.push("--json", "--skip-git-repo-check");
173
- args.push("-c", 'sandbox_mode="workspace-write"', "-c", "sandbox_workspace_write.network_access=true");
174
- if (model) args.push("-c", `model=${JSON.stringify(model)}`);
175
- if (effort) args.push("-c", `model_reasoning_effort=${JSON.stringify(effort)}`);
275
+ const config = [
276
+ 'sandbox_mode="workspace-write"',
277
+ "sandbox_workspace_write.network_access=true",
278
+ ...CONNECTOR_POLICY,
279
+ ...drafts ? GMAIL_DRAFTS : [],
280
+ ...codexConfig
281
+ ];
282
+ if (model) config.push(`model=${JSON.stringify(model)}`);
283
+ if (effort) config.push(`model_reasoning_effort=${JSON.stringify(effort)}`);
284
+ args.push(...configArgs(config));
176
285
  if (sessionId) args.push(sessionId);
177
286
  args.push(prompt);
178
287
  return args;
@@ -219,10 +328,17 @@ var Assistant = class _Assistant {
219
328
  config;
220
329
  hooks;
221
330
  spawnFn;
222
- constructor(config, hooks, spawnFn = spawn) {
331
+ execFn;
332
+ /** Refuses a Codex too old to hold to the connector rules; see `codex.ts`. */
333
+ codexGate;
334
+ /** What switches computer use off, once Codex has said; see `computerUseConfig`. */
335
+ codexOff = null;
336
+ constructor(config, hooks, spawnFn = spawn, execFn) {
223
337
  this.config = config;
224
338
  this.hooks = hooks;
225
339
  this.spawnFn = spawnFn;
340
+ this.execFn = execFn;
341
+ this.codexGate = new CodexGate(config.assistant.binPath, execFn);
226
342
  }
227
343
  get enabled() {
228
344
  return this.config.assistant.agent !== null;
@@ -329,6 +445,19 @@ ${typed}` : action.request : typed;
329
445
  });
330
446
  const prompt = previous ? resolved.request : composePrompt(await this.instructions(), context, resolved.request, this.config.profile);
331
447
  await mkdir(this.config.assistantDir, { recursive: true });
448
+ const refusal = agent === "codex" ? await this.codexGate.refusal() : null;
449
+ if (refusal) {
450
+ await this.finish(turn, { ok: false, error: refusal });
451
+ return turn;
452
+ }
453
+ let codexConfig;
454
+ try {
455
+ codexConfig = await this.computerUseConfig();
456
+ } catch (err) {
457
+ const error = err.code === "ENOENT" ? this.spawnError(err) : `computer use is off in the settings, and Codex could not confirm it: ${err.message}. Update Codex, or turn computer use on for the assistant`;
458
+ await this.finish(turn, { ok: false, error });
459
+ return turn;
460
+ }
332
461
  const adapter = ADAPTERS[agent];
333
462
  const args = adapter.args({
334
463
  prompt,
@@ -336,7 +465,9 @@ ${typed}` : action.request : typed;
336
465
  model: this.config.assistant.model,
337
466
  effort: this.config.assistant.effort,
338
467
  tools: this.config.assistant.tools,
339
- denyEdits: true
468
+ denyEdits: true,
469
+ drafts: true,
470
+ codexConfig
340
471
  });
341
472
  let child;
342
473
  try {
@@ -421,6 +552,20 @@ ${event.text}` : event.text;
421
552
  }
422
553
  this.hooks.onChange();
423
554
  }
555
+ /**
556
+ * What switches computer use off, when the assistant runs Codex and the user
557
+ * hasn't turned it on. Asked of Codex once and kept for the life of the
558
+ * process; settings that change it restart the dashboard anyway. A failure
559
+ * isn't kept, so the next turn asks again.
560
+ */
561
+ computerUseConfig() {
562
+ if (this.config.assistant.agent !== "codex" || this.config.assistant.computerUse) return Promise.resolve([]);
563
+ this.codexOff ??= computerUseOff(this.config.assistant.binPath, this.execFn).catch((err) => {
564
+ this.codexOff = null;
565
+ throw err;
566
+ });
567
+ return this.codexOff;
568
+ }
424
569
  spawnError(err) {
425
570
  const code = err.code;
426
571
  const bin = this.config.assistant.binPath;
@@ -450,6 +595,8 @@ ${event.text}` : event.text;
450
595
  };
451
596
 
452
597
  export {
598
+ CodexGate,
599
+ codexVersionWarning,
453
600
  QUICK_ACTIONS,
454
601
  ADAPTERS,
455
602
  Assistant
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  localDateKey
3
- } from "./chunk-OPASVYLR.js";
3
+ } from "./chunk-RNE27FQY.js";
4
4
 
5
5
  // src/sample.ts
6
6
  function timeOn(now, hour, minute = 0) {
package/dist/cli.js CHANGED
@@ -49,7 +49,7 @@ function shouldOpen(flag) {
49
49
  return flag && process.stdout.isTTY === true && !process.env.CI;
50
50
  }
51
51
  async function demo(open, profile) {
52
- const { demoEnv, demoIntegrations, demoProfile, writeDemoStore } = await import("./demo-OQLT3QWP.js");
52
+ const { demoEnv, demoIntegrations, demoProfile, writeDemoStore } = await import("./demo-KVFAVGO7.js");
53
53
  const chosen = demoProfile(profile);
54
54
  const store = await mkdtemp(join(tmpdir(), "daily-focus-demo-"));
55
55
  process.on("exit", () => rmSync(store, { recursive: true, force: true }));
@@ -65,7 +65,7 @@ async function demo(open, profile) {
65
65
  );
66
66
  }
67
67
  async function start(open, onStarted, options = {}) {
68
- const { runServer } = await import("./server-5O3LQVEY.js");
68
+ const { runServer } = await import("./server-SOCS3SZJ.js");
69
69
  await runServer({
70
70
  ...options,
71
71
  onStarted: (url) => {
@@ -162,20 +162,20 @@ async function main(argv) {
162
162
  }
163
163
  return start(open);
164
164
  case "service": {
165
- const { main: service } = await import("./service-6ZFOHPWN.js");
165
+ const { main: service } = await import("./service-NAAAV67B.js");
166
166
  process.exitCode = await service(values.remove ? ["--remove"] : []);
167
167
  return;
168
168
  }
169
169
  // These three are scripts that do their work as they load, and read their
170
170
  // own flags from the command line.
171
171
  case "init":
172
- await import("./init-DEVCAQZZ.js");
172
+ await import("./init-57WHZZNG.js");
173
173
  return;
174
174
  case "audit":
175
- await import("./audit-W3O6CLOF.js");
175
+ await import("./audit-ITRXIX6T.js");
176
176
  return;
177
177
  case "seed":
178
- await import("./seed-SMKNFYTC.js");
178
+ await import("./seed-VALN2TCQ.js");
179
179
  return;
180
180
  case "build-calendar":
181
181
  return buildCalendar();
@@ -1,15 +1,15 @@
1
1
  import {
2
2
  QUICK_ACTIONS
3
- } from "./chunk-GFCWPYEI.js";
3
+ } from "./chunk-SX5FPS3A.js";
4
4
  import {
5
5
  dateFromNow,
6
6
  sampleBrief,
7
7
  sampleGeneratedAt
8
- } from "./chunk-Q5OKXJXT.js";
8
+ } from "./chunk-UY6HQLGE.js";
9
9
  import {
10
10
  PROFILES,
11
11
  localDateKey
12
- } from "./chunk-OPASVYLR.js";
12
+ } from "./chunk-RNE27FQY.js";
13
13
  import "./chunk-BC2W7NND.js";
14
14
 
15
15
  // src/demo.ts
@@ -8,7 +8,7 @@ import {
8
8
  } from "./chunk-QKT2FAB5.js";
9
9
  import {
10
10
  loadConfig
11
- } from "./chunk-OPASVYLR.js";
11
+ } from "./chunk-RNE27FQY.js";
12
12
  import {
13
13
  PACKAGED,
14
14
  command
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  sampleBrief
3
- } from "./chunk-Q5OKXJXT.js";
3
+ } from "./chunk-UY6HQLGE.js";
4
4
  import {
5
5
  Store
6
- } from "./chunk-UM2EPNWR.js";
6
+ } from "./chunk-26JG3D5C.js";
7
7
  import {
8
8
  loadConfig
9
- } from "./chunk-OPASVYLR.js";
9
+ } from "./chunk-RNE27FQY.js";
10
10
  import {
11
11
  command
12
12
  } from "./chunk-BC2W7NND.js";
@@ -1,11 +1,17 @@
1
1
  import {
2
2
  ADAPTERS,
3
- Assistant
4
- } from "./chunk-GFCWPYEI.js";
3
+ Assistant,
4
+ CodexGate,
5
+ codexVersionWarning
6
+ } from "./chunk-SX5FPS3A.js";
5
7
  import {
6
8
  FOCUS_TEMPLATE,
7
9
  sourcesTemplate
8
10
  } from "./chunk-V2UN3JYL.js";
11
+ import {
12
+ TimeZoneKeeper,
13
+ validZone
14
+ } from "./chunk-AIQO2YAI.js";
9
15
  import {
10
16
  describeLink,
11
17
  linkStore,
@@ -20,7 +26,7 @@ import {
20
26
  stopSession,
21
27
  tempPathFor,
22
28
  writeJsonAtomic
23
- } from "./chunk-UM2EPNWR.js";
29
+ } from "./chunk-26JG3D5C.js";
24
30
  import {
25
31
  CLI_NAMES,
26
32
  LAUNCH_SETTINGS,
@@ -36,7 +42,7 @@ import {
36
42
  readDotEnv,
37
43
  resolveSchedule,
38
44
  scheduledRunDue
39
- } from "./chunk-OPASVYLR.js";
45
+ } from "./chunk-RNE27FQY.js";
40
46
  import {
41
47
  PACKAGED,
42
48
  command,
@@ -1488,7 +1494,8 @@ async function runHelper(appPath, selfAddresses) {
1488
1494
  const out = join(tmpdir(), `daily-focus-calendar-${process.pid}-${Date.now()}.json`);
1489
1495
  try {
1490
1496
  try {
1491
- await run2("/usr/bin/open", ["-n", "-a", appPath, "--args", out, selfAddresses.join(",")], {
1497
+ const zone = process.env.TZ ? ["--env", `TZ=${process.env.TZ}`] : [];
1498
+ await run2("/usr/bin/open", ["-n", "-a", appPath, ...zone, "--args", out, selfAddresses.join(",")], {
1492
1499
  timeout: HELPER_TIMEOUT_MS
1493
1500
  });
1494
1501
  } catch (err) {
@@ -2501,10 +2508,13 @@ var AgentRunner = class {
2501
2508
  config;
2502
2509
  hooks;
2503
2510
  spawnFn;
2504
- constructor(config, hooks, spawnFn = spawn) {
2511
+ /** Refuses a Codex too old to hold to the connector rules; see `codex.ts`. */
2512
+ codexGate;
2513
+ constructor(config, hooks, spawnFn = spawn, execFn) {
2505
2514
  this.config = config;
2506
2515
  this.hooks = hooks;
2507
2516
  this.spawnFn = spawnFn;
2517
+ this.codexGate = new CodexGate(config.agent.binPath, execFn);
2508
2518
  }
2509
2519
  get enabled() {
2510
2520
  return this.config.agent.cli !== null;
@@ -2627,7 +2637,9 @@ var AgentRunner = class {
2627
2637
  model: this.config.agent.model,
2628
2638
  effort: this.config.agent.effort,
2629
2639
  tools: [...briefWritingTools(), ...this.config.agent.tools],
2630
- denyEdits: false
2640
+ denyEdits: false,
2641
+ drafts: false,
2642
+ codexConfig: []
2631
2643
  });
2632
2644
  await this.spawnInto(run6, null, args, RUN_TIMEOUT_MS, "gave up after an hour");
2633
2645
  return run6;
@@ -2673,7 +2685,9 @@ var AgentRunner = class {
2673
2685
  effort: this.config.agent.effort,
2674
2686
  // The sources, and not the brief: a chat is given nothing to write.
2675
2687
  tools: this.config.agent.tools,
2676
- denyEdits: true
2688
+ denyEdits: true,
2689
+ drafts: false,
2690
+ codexConfig: []
2677
2691
  });
2678
2692
  await this.spawnInto(run6, turn, args, TURN_TIMEOUT_MS, "gave up after ten minutes");
2679
2693
  return turn;
@@ -2687,6 +2701,11 @@ var AgentRunner = class {
2687
2701
  const cli = run6.cli;
2688
2702
  const adapter = ADAPTERS[cli];
2689
2703
  await mkdir(this.config.dataDir, { recursive: true });
2704
+ const refusal2 = cli === "codex" ? await this.codexGate.refusal() : null;
2705
+ if (refusal2) {
2706
+ await this.finish(run6, turn, { ok: false, error: refusal2 });
2707
+ return;
2708
+ }
2690
2709
  let child;
2691
2710
  try {
2692
2711
  child = this.spawnFn(this.config.agent.binPath, args, {
@@ -3010,7 +3029,7 @@ var SETTINGS = [
3010
3029
  key: "DAILY_FOCUS_AGENT_TOOLS",
3011
3030
  group: "agent",
3012
3031
  label: "Tools Claude Code may use",
3013
- help: "In Claude Code's permission syntax, comma-separated. Add your calendar, chat and document connectors by name, or the brief reports them as unreachable. Ignored by Codex.",
3032
+ help: "In Claude Code's permission syntax, comma-separated. Add your calendar, chat and document connectors by name, or the brief reports them as unreachable. The default allows every Gmail tool, sending included; name the tools you want instead to narrow it. Ignored by Codex.",
3014
3033
  kind: "list",
3015
3034
  fallback: always("Bash(gh *), Bash(acli *), WebFetch, mcp__claude_ai_Gmail"),
3016
3035
  advanced: true
@@ -3037,7 +3056,7 @@ var SETTINGS = [
3037
3056
  key: "DAILY_FOCUS_ASSISTANT",
3038
3057
  group: "assistant",
3039
3058
  label: "Answer questions with",
3040
- help: "Puts an assistant in every item's panel. It reads and drafts, and never sends, posts or edits.",
3059
+ 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.",
3041
3060
  kind: "choice",
3042
3061
  choices: OFF_OR_CLI,
3043
3062
  fallback: always("Off")
@@ -3063,11 +3082,19 @@ var SETTINGS = [
3063
3082
  key: "DAILY_FOCUS_ASSISTANT_TOOLS",
3064
3083
  group: "assistant",
3065
3084
  label: "Tools Claude Code may use",
3066
- help: "In Claude Code's permission syntax, comma-separated. Editing tools are denied whatever this says. Ignored by Codex.",
3085
+ 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.",
3067
3086
  kind: "list",
3068
3087
  fallback: always("Bash(gh *), Bash(acli *), WebFetch, mcp__claude_ai_Gmail"),
3069
3088
  advanced: true
3070
3089
  },
3090
+ {
3091
+ key: "DAILY_FOCUS_ASSISTANT_COMPUTER_USE",
3092
+ group: "assistant",
3093
+ label: "Computer use",
3094
+ 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.",
3095
+ kind: "flag",
3096
+ fallback: always("Off")
3097
+ },
3071
3098
  {
3072
3099
  key: "DAILY_FOCUS_ASSISTANT_BIN",
3073
3100
  group: "assistant",
@@ -3686,6 +3713,8 @@ async function startServer(env, options = {}) {
3686
3713
  const store = new Store(config);
3687
3714
  await store.ensureDataDir();
3688
3715
  const storeId = await store.fingerprint();
3716
+ const zone = new TimeZoneKeeper(config.timeZoneFile, options.readSystemZone);
3717
+ await zone.start();
3689
3718
  for (const outcome of await linkStore(config)) {
3690
3719
  if (outcome.result === "linked") console.log(`[daily-focus] linked ${describeLink(config, outcome)}`);
3691
3720
  if (outcome.result === "failed") console.warn(`[daily-focus] could not link ${outcome.name}: ${outcome.detail}`);
@@ -3715,6 +3744,15 @@ async function startServer(env, options = {}) {
3715
3744
  restartWhenIdle();
3716
3745
  }
3717
3746
  });
3747
+ let codexWarnings = [];
3748
+ const codexBins = /* @__PURE__ */ new Set();
3749
+ if (config.agent.cli === "codex") codexBins.add(config.agent.binPath);
3750
+ if (config.assistant.agent === "codex") codexBins.add(config.assistant.binPath);
3751
+ void Promise.all([...codexBins].map((bin) => codexVersionWarning(bin))).then((found) => {
3752
+ codexWarnings = [...new Set(found.filter((warning) => warning !== null))];
3753
+ for (const warning of codexWarnings) console.warn(`[daily-focus] ${warning}`);
3754
+ if (codexWarnings.length > 0) void broadcast();
3755
+ });
3718
3756
  function requestRestart() {
3719
3757
  restart = options.onRestart ? "waiting" : "manual";
3720
3758
  restartWhenIdle();
@@ -3742,7 +3780,7 @@ async function startServer(env, options = {}) {
3742
3780
  tickets: tickets.view(actions, now),
3743
3781
  assistant: assistant.view(),
3744
3782
  agentRun: agent.view(now),
3745
- setupWarnings: [...configWarnings, ...linkWarning ? [linkWarning] : []],
3783
+ setupWarnings: [...configWarnings, ...codexWarnings, ...linkWarning ? [linkWarning] : []],
3746
3784
  restart,
3747
3785
  setup,
3748
3786
  assetVersion,
@@ -3781,6 +3819,8 @@ data: ${payload}
3781
3819
  basename(tempPathFor(config.pullsFile)),
3782
3820
  basename(config.ticketsFile),
3783
3821
  basename(tempPathFor(config.ticketsFile)),
3822
+ basename(config.timeZoneFile),
3823
+ basename(tempPathFor(config.timeZoneFile)),
3784
3824
  // Appended by this process, which broadcasts on its own.
3785
3825
  basename(config.assistantLogFile),
3786
3826
  basename(config.agentLogFile)
@@ -3822,10 +3862,23 @@ data: ${payload}
3822
3862
  await checkPresence();
3823
3863
  await assistant.start();
3824
3864
  await agent.start();
3825
- async function clockTick() {
3865
+ let ticks = Promise.resolve();
3866
+ function clockTick() {
3867
+ ticks = ticks.then(tickOnce).catch((err) => {
3868
+ console.error(`[daily-focus] the morning clock failed: ${err.message}`);
3869
+ });
3870
+ return ticks;
3871
+ }
3872
+ async function tickOnce() {
3873
+ if (await zone.checkSystem()) zoneMoved();
3826
3874
  if (restart === "waiting" || await firstBriefPending(config)) return;
3827
3875
  await agent.tick();
3828
3876
  }
3877
+ function zoneMoved() {
3878
+ console.log(`[daily-focus] time zone is now ${zone.current}`);
3879
+ void calendar.refresh();
3880
+ void broadcast();
3881
+ }
3829
3882
  void clockTick();
3830
3883
  const agentClock = setInterval(() => {
3831
3884
  void clockTick();
@@ -3909,6 +3962,26 @@ data: ${JSON.stringify(await buildState())}
3909
3962
  sendState(res, await buildState());
3910
3963
  return;
3911
3964
  }
3965
+ if (path === "/api/timezone" && req.method === "POST") {
3966
+ let body;
3967
+ try {
3968
+ body = JSON.parse(await readBody(req));
3969
+ } catch (err) {
3970
+ sendJSON(res, 400, { error: `invalid JSON body: ${err.message}` });
3971
+ return;
3972
+ }
3973
+ const timeZone = validZone(body?.timeZone);
3974
+ if (!timeZone) {
3975
+ sendJSON(res, 400, { error: '"timeZone" must be an IANA time zone, such as Europe/Copenhagen' });
3976
+ return;
3977
+ }
3978
+ if (await zone.report(timeZone)) {
3979
+ zoneMoved();
3980
+ await clockTick();
3981
+ }
3982
+ sendState(res, await buildState());
3983
+ return;
3984
+ }
3912
3985
  if (path === "/api/calendar/refresh" && req.method === "POST") {
3913
3986
  await calendar.refresh();
3914
3987
  sendState(res, await buildState());
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  loadConfig
3
- } from "./chunk-OPASVYLR.js";
3
+ } from "./chunk-RNE27FQY.js";
4
4
  import {
5
5
  IN_NPX_CACHE,
6
6
  PACKAGED,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "daily-focus",
3
- "version": "1.1.1-dev.2",
3
+ "version": "1.2.0-dev.4",
4
4
  "description": "An interactive dashboard of what you should focus on today at work",
5
5
  "keywords": [
6
6
  "dashboard",
package/public/app.js CHANGED
@@ -1010,6 +1010,9 @@ function subscribe(onState, onError) {
1010
1010
  source.addEventListener("open", () => onError(null));
1011
1011
  return () => source.close();
1012
1012
  }
1013
+ function postTimeZone(timeZone) {
1014
+ return post("/api/timezone", { timeZone });
1015
+ }
1013
1016
  function postSession(body) {
1014
1017
  return post("/api/session", body);
1015
1018
  }
@@ -1293,15 +1296,28 @@ function ThemeToggle({ ui: ui2, handlers: handlers2 }) {
1293
1296
  }
1294
1297
 
1295
1298
  // client/format.ts
1296
- var timeFmt = new Intl.DateTimeFormat(void 0, { hour: "2-digit", minute: "2-digit" });
1297
- var dayFmt = new Intl.DateTimeFormat(void 0, {
1299
+ var DAY_FORMAT = {
1298
1300
  weekday: "long",
1299
1301
  day: "numeric",
1300
1302
  month: "long"
1301
- });
1302
- var shortDayFmt = new Intl.DateTimeFormat(void 0, { day: "numeric", month: "short" });
1303
- var shortDayYearFmt = new Intl.DateTimeFormat(void 0, { day: "numeric", month: "short", year: "numeric" });
1304
- var weekdayFmt = new Intl.DateTimeFormat(void 0, { weekday: "long" });
1303
+ };
1304
+ function formatters(timeZone) {
1305
+ return {
1306
+ time: new Intl.DateTimeFormat(void 0, { timeZone, hour: "2-digit", minute: "2-digit" }),
1307
+ day: new Intl.DateTimeFormat(void 0, { timeZone, ...DAY_FORMAT }),
1308
+ shortDay: new Intl.DateTimeFormat(void 0, { timeZone, day: "numeric", month: "short" }),
1309
+ shortDayYear: new Intl.DateTimeFormat(void 0, { timeZone, day: "numeric", month: "short", year: "numeric" }),
1310
+ weekday: new Intl.DateTimeFormat(void 0, { timeZone, weekday: "long" })
1311
+ };
1312
+ }
1313
+ var clockFormats = formatters();
1314
+ var dateFormats = formatters("UTC");
1315
+ var DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
1316
+ function formatted(value, kind) {
1317
+ const bare = typeof value === "string" && DATE_ONLY.test(value);
1318
+ const d4 = bare ? /* @__PURE__ */ new Date(`${value}T00:00:00Z`) : parseDate(value);
1319
+ return d4 && !Number.isNaN(d4.getTime()) ? (bare ? dateFormats : clockFormats)[kind].format(d4) : "";
1320
+ }
1305
1321
  function parseDate(value) {
1306
1322
  if (typeof value !== "string" || value === "") return null;
1307
1323
  const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
@@ -1310,20 +1326,16 @@ function parseDate(value) {
1310
1326
  return Number.isNaN(d4.getTime()) ? null : d4;
1311
1327
  }
1312
1328
  function formatTime(value) {
1313
- const d4 = parseDate(value);
1314
- return d4 ? timeFmt.format(d4) : "";
1329
+ return formatted(value, "time");
1315
1330
  }
1316
1331
  function formatDay(value) {
1317
- const d4 = parseDate(value);
1318
- return d4 ? dayFmt.format(d4) : "";
1332
+ return formatted(value, "day");
1319
1333
  }
1320
1334
  function formatShortDay(value) {
1321
- const d4 = parseDate(value);
1322
- return d4 ? shortDayFmt.format(d4) : "";
1335
+ return formatted(value, "shortDay");
1323
1336
  }
1324
1337
  function formatWeekday(value) {
1325
- const d4 = parseDate(value);
1326
- return d4 ? weekdayFmt.format(d4) : "";
1338
+ return formatted(value, "weekday");
1327
1339
  }
1328
1340
  var dayKeyFmt = null;
1329
1341
  var calendarZone;
@@ -1331,9 +1343,11 @@ function setCalendarZone(timeZone) {
1331
1343
  if (timeZone === calendarZone) return;
1332
1344
  calendarZone = timeZone;
1333
1345
  try {
1346
+ clockFormats = formatters(timeZone);
1334
1347
  dayKeyFmt = timeZone ? new Intl.DateTimeFormat("en-US", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }) : null;
1335
1348
  } catch {
1336
1349
  dayKeyFmt = null;
1350
+ clockFormats = formatters();
1337
1351
  }
1338
1352
  }
1339
1353
  function dayKey(d4) {
@@ -1370,8 +1384,8 @@ function daysFromToday(value, now = /* @__PURE__ */ new Date()) {
1370
1384
  }
1371
1385
  const target = parseDate(value);
1372
1386
  if (!target) return null;
1373
- const a4 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
1374
- const b4 = new Date(target.getFullYear(), target.getMonth(), target.getDate()).getTime();
1387
+ const a4 = Date.UTC(...splitKey(dayKey(now)));
1388
+ const b4 = Date.UTC(...splitKey(dayKey(target)));
1375
1389
  return Math.round((b4 - a4) / 864e5);
1376
1390
  }
1377
1391
  function relativeDay(value, now = /* @__PURE__ */ new Date()) {
@@ -1402,7 +1416,7 @@ function relativeTime(value, now = /* @__PURE__ */ new Date()) {
1402
1416
  if (hours < 48) return `${hours} h ago`;
1403
1417
  const days = Math.round(hours / 24);
1404
1418
  if (days <= 30) return `${days} days ago`;
1405
- return d4.getFullYear() === now.getFullYear() ? formatShortDay(value) : shortDayYearFmt.format(d4);
1419
+ return dayKey(d4).slice(0, 4) === dayKey(now).slice(0, 4) ? formatShortDay(value) : formatted(value, "shortDayYear");
1406
1420
  }
1407
1421
  function firstLine(text) {
1408
1422
  const line = String(text).split("\n").find((l5) => l5.trim() !== "") ?? "";
@@ -3458,6 +3472,46 @@ function parkButton(row, ui2, handlers2, menuKey) {
3458
3472
  );
3459
3473
  }
3460
3474
 
3475
+ // client/zone.ts
3476
+ var ZONE_CHECK_MS = 6e4;
3477
+ function browserZone() {
3478
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
3479
+ }
3480
+ function sameZone(a4, b4) {
3481
+ if (a4 === b4) return true;
3482
+ try {
3483
+ const canonical = (zone2) => new Intl.DateTimeFormat("en", { timeZone: zone2 }).resolvedOptions().timeZone;
3484
+ return canonical(a4) === canonical(b4);
3485
+ } catch {
3486
+ return false;
3487
+ }
3488
+ }
3489
+ function watchZone(deps) {
3490
+ let seen = deps.browserZone();
3491
+ let sending = null;
3492
+ function tellIfElsewhere() {
3493
+ const here = deps.browserZone();
3494
+ const there = deps.dashboardZone();
3495
+ if (there === void 0 || sameZone(here, there) || sending === here) return;
3496
+ sending = here;
3497
+ deps.send(here).catch((err) => console.warn(`[daily-focus] could not tell the dashboard this browser is in ${here}:`, err)).finally(() => {
3498
+ sending = null;
3499
+ });
3500
+ }
3501
+ return {
3502
+ look() {
3503
+ seen = deps.browserZone();
3504
+ if (deps.visible()) tellIfElsewhere();
3505
+ },
3506
+ check() {
3507
+ const here = deps.browserZone();
3508
+ if (here === seen) return;
3509
+ seen = here;
3510
+ tellIfElsewhere();
3511
+ }
3512
+ };
3513
+ }
3514
+
3461
3515
  // client/chrome.ts
3462
3516
  function Header({ state: state2, ui: ui2, handlers: handlers2 }) {
3463
3517
  const error = ui2.connectionError.value;
@@ -3468,7 +3522,8 @@ function Header({ state: state2, ui: ui2, handlers: handlers2 }) {
3468
3522
  "div",
3469
3523
  { class: "page-header__titles" },
3470
3524
  el("h1", null, "Daily Focus"),
3471
- el("p", { class: "page-header__date", id: "header-date" }, state2 ? headerDate(state2) : "")
3525
+ el("p", { class: "page-header__date", id: "header-date" }, state2 ? headerDate(state2) : ""),
3526
+ state2 ? zoneLine(state2) : null
3472
3527
  ),
3473
3528
  el(
3474
3529
  "div",
@@ -3562,6 +3617,11 @@ function headerDate(state2) {
3562
3617
  const today = dayKey(new Date(state2.now));
3563
3618
  return formatDay(state2.brief.date ?? today) || formatDay(today);
3564
3619
  }
3620
+ function zoneLine(state2) {
3621
+ if (sameZone(browserZone(), state2.timeZone)) return null;
3622
+ const at = state2.agentRun?.schedule ? ` \xB7 Morning agent at ${state2.agentRun.schedule.at}` : "";
3623
+ return el("p", { class: "page-header__zone", id: "header-zone" }, `Times in ${state2.timeZone}${at}`);
3624
+ }
3565
3625
  function freshness(state2, handlers2) {
3566
3626
  let text;
3567
3627
  let stale;
@@ -3717,7 +3777,7 @@ function HelpDialog(_props) {
3717
3777
  el(
3718
3778
  "p",
3719
3779
  { class: "help__note" },
3720
- `Done, snoozed and dismissed all tell the morning agent to leave an item out of tomorrow's brief. A note is sent along with it as free text. On both boards only snooze and note apply: a pull request stays until it's merged or closed, and a ticket whose status looks wrong is put right in Jira rather than here. Click a card to open its panel, where its notes and the assistant live; the panel stays on the item it was opened for. The assistant is a CLI run on your machine; it reads and drafts, never sends or posts. Click "updated \u2026 ago" in the header to open the morning agent's latest run: its report, earlier runs, and a place to ask it why it did what it did.`
3780
+ `Done, snoozed and dismissed all tell the morning agent to leave an item out of tomorrow's brief. A note is sent along with it as free text. On both boards only snooze and note apply: a pull request stays until it's merged or closed, and a ticket whose status looks wrong is put right in Jira rather than here. Click a card to open its panel, where its notes and the assistant live; the panel stays on the item it was opened for. The assistant is a CLI run on your machine, told to read and draft, never to send or post. Click "updated \u2026 ago" in the header to open the morning agent's latest run: its report, earlier runs, and a place to ask it why it did what it did.`
3721
3781
  ),
3722
3782
  el("form", { method: "dialog" }, el("button", { type: "submit", class: "button" }, "Close"))
3723
3783
  );
@@ -5164,15 +5224,31 @@ j3(() => {
5164
5224
  installKeyboard(handlers);
5165
5225
  await refresh();
5166
5226
  startLocalTick();
5227
+ var zone = watchZone({
5228
+ browserZone,
5229
+ dashboardZone: () => state.value?.timeZone,
5230
+ visible: () => document.visibilityState === "visible",
5231
+ send: async (timeZone) => adoptState(await postTimeZone(timeZone))
5232
+ });
5233
+ zone.look();
5234
+ document.addEventListener("visibilitychange", () => zone.look());
5235
+ window.addEventListener("focus", () => zone.look());
5236
+ setInterval(() => zone.check(), ZONE_CHECK_MS);
5167
5237
  followLocation();
5168
5238
  window.addEventListener("popstate", followLocation);
5169
5239
  window.addEventListener("hashchange", followLocation);
5240
+ var reconnected = false;
5170
5241
  subscribe(
5171
5242
  // A server push is authoritative, but must not yank a row out from under an
5172
5243
  // in-flight click; adoptState keeps anything pending at its optimistic status.
5173
- (next) => adoptState(next),
5244
+ (next) => {
5245
+ adoptState(next);
5246
+ if (reconnected) zone.look();
5247
+ reconnected = false;
5248
+ },
5174
5249
  (err) => {
5175
5250
  ui.connectionError.value = err;
5251
+ if (err === null) reconnected = true;
5176
5252
  }
5177
5253
  );
5178
5254
  //# sourceMappingURL=app.js.map
package/public/style.css CHANGED
@@ -161,6 +161,12 @@ a {
161
161
  gap: 8px;
162
162
  }
163
163
 
164
+ .page-header__zone {
165
+ margin: 3px 0 0;
166
+ color: var(--ink-2);
167
+ font-size: 12px;
168
+ }
169
+
164
170
  .freshness {
165
171
  color: var(--ink-3);
166
172
  font-size: 13px;