symbiot 0.41.2 → 0.41.3

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
@@ -55,6 +55,11 @@ Tabs:
55
55
  **no AI key**; reviews and suggestions use your chosen model. While the scan runs
56
56
  the Map shows its progress. Under the graph is the [experimental](#screens-blueprints-for-screen-automation)
57
57
  **Screens** view.
58
+ - **Dashboard** — everything you [watch](#screens-blueprints-for-screen-automation)
59
+ side by side: a card for each page (your inbox, GitHub, WhatsApp, any other) with
60
+ how many new since yesterday (or in the last 3 or 7 days), its latest brief, the
61
+ newest items, and **Open**, **Draft a reply** and **Check now** on them. The tab
62
+ shows the count, so you see what's waiting from any tab.
58
63
  - **Drift** — the [`symbiot drift`](#whats-out-of-sync-symbiot-drift) report, with
59
64
  a "fetch latest" toggle (and an [experimental](#experimental) "check CI").
60
65
  - **Week / Standup / Todo** — the write-ups (these use your chosen AI).
@@ -113,6 +118,12 @@ repo is now archived), without Electron:
113
118
  then opens that copy's window. Switch it off to remove the entry. It needs an
114
119
  installed Symbiot (`npm install -g symbiot`), not `npx`.
115
120
 
121
+ For a script or tray of your own: `isAppRunningWeekly()` (from
122
+ `symbiot/index.mjs`) says whether a running `symbiot app` already writes the
123
+ week, with no port or token to know; and `runWeekly(produce, { notify: false })`
124
+ (from `symbiot/desktop.mjs`) writes and saves the week in the same `weeks/`
125
+ format without the notification, for a caller that tells you itself.
126
+
116
127
  ### On your phone (Android, in Termux)
117
128
 
118
129
  Experimental, and tried on one phone so far (a Galaxy A26, Android 16), where it
@@ -620,7 +631,9 @@ there with what it has seen. It only reads: no screenshot is saved and nothing
620
631
  on the page is pressed or typed. The first read just learns what's there, so it
621
632
  never announces your whole inbox. After that, anything new (a new email, a new
622
633
  reply in a thread) gets a desktop notification and goes under **Watching** in
623
- Screens, newest first. A row is the same row whether its time reads `9:05 AM`,
634
+ Screens, newest first, and on the page's card in the **Dashboard** tab. A chat
635
+ works the same way: map `web.whatsapp.com` once you've signed in, watch it, and new
636
+ messages (counted as WhatsApp messages in Standup) arrive with your mail and GitHub. A row is the same row whether its time reads `9:05 AM`,
624
637
  `Oct 5` or `2 hours ago`, or you've read it since. On a page with no rows it
625
638
  watches the links instead. If the site has signed you out, the watch says so,
626
639
  and notifies you once. With
@@ -633,6 +646,7 @@ symbiot watch add <screen id> --every 15 # watch a mapped page (or: symbiot wa
633
646
  symbiot watch add github # your GitHub notifications (see below)
634
647
  symbiot watch new --hours 24 # what's new, newest first (JSON)
635
648
  symbiot watch check # read them all now (JSON)
649
+ symbiot watch draft <id> # Draft a reply to a new email (see below)
636
650
  symbiot watch brief on # your AI says what needs you (off to stop)
637
651
  symbiot watch # what you watch, and what's new
638
652
  symbiot watch remove <id>
@@ -666,7 +680,8 @@ your mail's site (`mail.google.com`) under Trusted sites. Each email gets a fold
666
680
  of its own in `~/.config/symbiot/drafts` (yours only) with the agent's brief, and
667
681
  the run shows in the Agents tab like any other: what it asks (a date only you
668
682
  know, say) is answered there. The agent sees the email's text, so it reaches
669
- whatever AI your agent uses.
683
+ whatever AI your agent uses. From a terminal, `symbiot watch new` marks the
684
+ emails `"mail": true`, and `symbiot watch draft <id>` does the same as the button.
670
685
 
671
686
  **In Standup.** Standup ends with what's waiting on you since yesterday, counted
672
687
  from what Watch found: `Waiting on you: 3 emails, 2 GitHub notifications`. Symbiot
package/agents.mjs CHANGED
@@ -292,7 +292,7 @@ const readSymbiot = (path, f) => { try { return readFileSync(join(path, ".symbio
292
292
  // bullet under Questions is a question with no options.
293
293
  function parseQuestions(md) {
294
294
  const questions = [], suggestions = []; let sec = "q", cur = null;
295
- const clip = (s) => String(s).trim().slice(0, 300);
295
+ const clip = (s) => String(s).trim().slice(0, 1000); // as long as a task holds (addTask), so an idea added in one click arrives whole
296
296
  for (const raw of String(md || "").split(/\r?\n/)) {
297
297
  const l = raw.trim(); if (!l) continue;
298
298
  let m;
package/desktop.mjs CHANGED
@@ -55,16 +55,19 @@ function latestWeek() {
55
55
  }
56
56
  function weeklyState() { return { ...weeklyCfg(), latest: latestWeek() }; }
57
57
  // Write the week with `produce` (index.mjs), save it and notify. `last` is set
58
- // first, so a slow or failing run is never retried every minute.
59
- async function runWeekly(produce) {
58
+ // first, so a slow or failing run is never retried every minute. notify: false
59
+ // skips the notification, for a caller that tells you itself (the saved file
60
+ // and what's returned are the same either way).
61
+ async function runWeekly(produce, { notify: on = true } = {}) {
62
+ const notify = (body) => on && desktopNotify("Symbiot", body);
60
63
  const cfg = loadConfig(); cfg.weekly = { ...weeklyCfg(cfg), last: Date.now() }; saveConfig(cfg);
61
64
  const r = await produce("week");
62
- if (r.error === "not-connected") { desktopNotify("Symbiot", "Time for your weekly update. Connect an AI in Symbiot's Settings and it writes it for you."); return { error: r.error }; }
63
- if (/^\(?couldn't reach the model/i.test(String(r.text || ""))) { desktopNotify("Symbiot", "Couldn't write your week: the model didn't answer. Try the Week tab."); return { error: r.text }; }
65
+ if (r.error === "not-connected") { notify("Time for your weekly update. Connect an AI in Symbiot's Settings and it writes it for you."); return { error: r.error }; }
66
+ if (/^\(?couldn't reach the model/i.test(String(r.text || ""))) { notify("Couldn't write your week: the model didn't answer. Try the Week tab."); return { error: r.text }; }
64
67
  const file = join(WEEKS_DIR, localDay() + ".md");
65
68
  try { mkdirSync(WEEKS_DIR, { recursive: true }); writeFileSync(file, String(r.text || "").trim() + (r.footer ? FOOT + r.footer : "") + "\n"); }
66
69
  catch (e) { return { error: "Couldn't save the write-up: " + ((e && e.message) || e) }; }
67
- desktopNotify("Symbiot", "Your week is written. Open the Week tab in Symbiot, or " + file);
70
+ notify("Your week is written. Open the Week tab in Symbiot, or " + file);
68
71
  return { ok: true, file, text: r.text };
69
72
  }
70
73
  // Checks once a minute while the app runs. Returns a stop function.
package/index.mjs CHANGED
@@ -25,6 +25,7 @@
25
25
 
26
26
  import { spawn } from "node:child_process";
27
27
  import { homedir } from "node:os";
28
+ import { join } from "node:path";
28
29
  import { realpathSync } from "node:fs";
29
30
  import { createInterface } from "node:readline";
30
31
  import { fileURLToPath } from "node:url";
@@ -38,9 +39,9 @@ import { buildTasksMd, taskType, shipChanges, shipWithBump, bumpOffer, learnNpm,
38
39
  import { produce, mailState, setMail, sentMail } from "./writeups.mjs";
39
40
  import { loadScreens, screenImage, blueprint } from "./screens.mjs";
40
41
  import { mapPage, pressRegion, typeRegion, signIn, isTrusted } from "./headless.mjs";
41
- import { watchState, addWatch, removeWatch, newsSince, checkWatch, setBrief } from "./watch.mjs";
42
+ import { watchState, addWatch, removeWatch, newsSince, checkWatch, setBrief, isMail, draftReply } from "./watch.mjs";
42
43
  import { PORT as PHONE_PORT, phoneState, pairComputer, pollComputer, forgetComputer } from "./phone.mjs";
43
- import { startApp, updateCmd } from "./server.mjs";
44
+ import { startApp, updateCmd, isAppRunningWeekly } from "./server.mjs";
44
45
 
45
46
  // ---- tiny arg parse --------------------------------------------------------
46
47
  const argv = process.argv.slice(2);
@@ -369,7 +370,13 @@ async function cmdWatch() {
369
370
  for (const n of news.slice(0, 20)) console.log(` ${c.d(new Date(n.ts).toLocaleString())} ${n.text.slice(0, 110)} ${c.d(n.name.slice(0, 30))}`);
370
371
  return;
371
372
  }
372
- if (sub === "new") return out(newsSince(hours));
373
+ if (sub === "new") { const mail = new Set(watchState().watches.filter((w) => isMail(w.url)).map((w) => w.id)); return out(newsSince(hours).map((n) => (mail.has(n.watch) ? { ...n, mail: true } : n))); }
374
+ // the same as the app's Draft a reply button: through the app when it runs, so its Agents tab tracks the run
375
+ if (sub === "draft") {
376
+ if (!a1) return out({ error: "Give the email's id: symbiot watch new lists them, and the ones marked \"mail\": true can get a reply." });
377
+ const r = (await viaApp("/api/watch/draft", { id: a1 })) || draftReply(a1);
378
+ return out(r.ok ? { ...r, next: "Your agent is writing the reply and leaves it in Drafts, never sent. Its log is in " + join(r.dir, ".symbiot", "agent.log") + " (and the app's Agents tab)." } : r);
379
+ }
373
380
  if (sub === "add") { const screen = loadScreens().some((s) => s.id === a1) ? a1 : ""; return out(addWatch({ screen, site: screen ? "" : a1, every: flag("every", 15) })); }
374
381
  if (sub === "remove") return out(removeWatch(String(a1 || "")));
375
382
  if (sub === "brief") { if (a1 === "on" || a1 === "off") return out(setBrief(a1 === "on")); return out({ brief: watchState().brief, briefs: watchState().briefs }); }
@@ -387,6 +394,9 @@ async function cmdWatch() {
387
394
  failed CI runs (through gh when it's signed in)
388
395
  symbiot watch new [--hours 24] what's new, newest first (JSON)
389
396
  symbiot watch check [id] read them now (JSON)
397
+ symbiot watch draft <id> your agent drafts a reply to that new email
398
+ ("mail": true in watch new) and leaves it in
399
+ Drafts, never sent, like Draft a reply in the app
390
400
  symbiot watch brief [on|off] your AI says what needs you, and what can wait
391
401
  symbiot watch remove <id> stop watching it
392
402
  While the app runs it reads each page every few minutes in its hidden browser
@@ -548,5 +558,5 @@ const isMain = (() => {
548
558
  })();
549
559
  if (isMain) main();
550
560
 
551
- export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, taskType, EMBEDDED_UI, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, fillHandoff, handoffCmd, setHandoffCmd, ORCA_CLAUDE_CMD, CLAUDE_CMD, HANDOFF_PROMPT, shipChanges, shipWithBump, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, syncTasks, pendingReview, unreleased, publishesOnMerge, addTask, approveRepo, approveChanges, sendBack, pushTasks, semverGt, updateCmd, parseQuestions, agentQuestions };
561
+ export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, taskType, EMBEDDED_UI, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, fillHandoff, handoffCmd, setHandoffCmd, ORCA_CLAUDE_CMD, CLAUDE_CMD, HANDOFF_PROMPT, shipChanges, shipWithBump, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, syncTasks, pendingReview, unreleased, publishesOnMerge, addTask, approveRepo, approveChanges, sendBack, pushTasks, semverGt, updateCmd, parseQuestions, agentQuestions, isAppRunningWeekly };
552
562
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.41.2",
3
+ "version": "0.41.3",
4
4
  "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
package/server.mjs CHANGED
@@ -19,7 +19,7 @@ import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailSt
19
19
  import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
20
20
  import { mapPage, pressRegion, typeRegion, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
21
21
  import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart } from "./desktop.mjs";
22
- import { watchState, addWatch, setEvery, removeWatch, clearNews, checkWatch, startWatches, setBrief, draftReply } from "./watch.mjs";
22
+ import { watchState, addWatch, setEvery, removeWatch, clearNews, checkWatch, startWatches, setBrief, draftReply, watchBoard } from "./watch.mjs";
23
23
  import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
24
24
 
25
25
  // The in-app update installs the EXACT newest version (not the `latest` tag, which
@@ -69,6 +69,21 @@ function openApp(url) {
69
69
  return "browser tab";
70
70
  } catch { return null; }
71
71
  }
72
+ // What the `symbiot app` already serving on this computer says at /api/<path>,
73
+ // or null when none answers. Knows the app's port and token, so a caller (a
74
+ // tray, a script, startApp's single-instance check) doesn't have to.
75
+ async function askRunningApp(path, { port, token, ms = 800 } = {}) {
76
+ const cfg = loadConfig(); token = token || cfg.appToken; if (!token) return null;
77
+ port = port || Number(process.env.SYMBIOT_PORT || cfg.appPort) || 7391;
78
+ const ctrl = new AbortController(), to = setTimeout(() => ctrl.abort(), ms);
79
+ try {
80
+ const r = await fetch(`http://127.0.0.1:${port}/api/${path}`, { headers: { "x-symbiot-token": token }, signal: ctrl.signal });
81
+ return r.ok ? await r.json() : null;
82
+ } catch { return null; } finally { clearTimeout(to); }
83
+ }
84
+ // True when a `symbiot app` is running with the weekly write-up on, so it
85
+ // writes the week (and notifies) itself and nothing else needs to.
86
+ async function isAppRunningWeekly() { const d = await askRunningApp("desktop"); return !!(d && d.weekly && d.weekly.on); }
72
87
  // How long the app keeps Screens' hidden browser open after an action.
73
88
  const BROWSER_KEEP = 5 * 60 * 1000;
74
89
  const PLAIN_COLOURS = { g: (s) => s, d: (s) => s, b: (s) => s, y: (s) => s };
@@ -92,21 +107,14 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
92
107
  // open a second window and exit, leaving nothing serving either window.
93
108
  const RELAUNCH = process.env.SYMBIOT_RELAUNCH === "1"; delete process.env.SYMBIOT_RELAUNCH;
94
109
  if (!process.env.SYMBIOT_FORCE_NEW && !RELAUNCH) {
95
- try {
96
- const ctrl = new AbortController(); const to = setTimeout(() => ctrl.abort(), 800);
97
- const r = await fetch(`http://127.0.0.1:${PORT}/api/ping`, { headers: { "x-symbiot-token": TOKEN }, signal: ctrl.signal }).catch(() => null);
98
- clearTimeout(to);
99
- if (r && r.ok) {
100
- const p = await r.json().catch(() => ({}));
101
- if (p && p.version) {
102
- const url = `http://127.0.0.1:${PORT}/?t=${TOKEN}`; const how = process.env.SYMBIOT_NO_OPEN === "1" ? "" : openApp(url);
103
- console.log(`\n${c.g("●")} ${c.b("Symbiot")} is already running (v${p.version}) at ${c.b(url)}`);
104
- console.log(how ? c.d(` Opened the existing window (a ${how}).`) : c.d(" Open that URL in your browser."));
105
- console.log(c.d(" (Not starting a second copy. Set SYMBIOT_FORCE_NEW=1 to force one.)"));
106
- return;
107
- }
108
- }
109
- } catch {}
110
+ const p = await askRunningApp("ping", { port: PORT, token: TOKEN });
111
+ if (p && p.version) {
112
+ const url = `http://127.0.0.1:${PORT}/?t=${TOKEN}`; const how = process.env.SYMBIOT_NO_OPEN === "1" ? "" : openApp(url);
113
+ console.log(`\n${c.g("●")} ${c.b("Symbiot")} is already running (v${p.version}) at ${c.b(url)}`);
114
+ console.log(how ? c.d(` Opened the existing window (a ${how}).`) : c.d(" Open that URL in your browser."));
115
+ console.log(c.d(" (Not starting a second copy. Set SYMBIOT_FORCE_NEW=1 to force one.)"));
116
+ return;
117
+ }
110
118
  }
111
119
  // Screens' hidden browser stays open between map, press and type (headless.mjs),
112
120
  // so an agent can type into a field and then press a separate button there.
@@ -201,6 +209,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
201
209
  if (u.pathname === "/api/screens/signin" && req.method === "POST") { const b = await readBody(req); return json(res, await signIn(b.site)); }
202
210
  // Watch (watch.mjs): a mapped page read again every few minutes, and what's new on it.
203
211
  if (u.pathname === "/api/watch") return json(res, watchState());
212
+ if (u.pathname === "/api/watch/board") return json(res, watchBoard(Math.min(168, Math.max(1, Number(u.searchParams.get("hours")) || 24))));
204
213
  if (u.pathname === "/api/watch/add" && req.method === "POST") { const b = await readBody(req); return json(res, addWatch({ screen: b.screen ? String(b.screen) : "", site: b.site, every: b.every })); }
205
214
  if (u.pathname === "/api/watch/every" && req.method === "POST") { const b = await readBody(req); return json(res, setEvery(String(b.id || ""), b.every)); }
206
215
  if (u.pathname === "/api/watch/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeWatch(String(b.id || ""))); }
@@ -222,7 +231,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
222
231
  // What symbiot-desktop added (desktop.mjs): the weekly write-up, start at login.
223
232
  if (u.pathname === "/api/desktop") return json(res, { weekly: weeklyState(), autostart: autostartState() });
224
233
  if (u.pathname === "/api/desktop/weekly" && req.method === "POST") { const b = await readBody(req); return json(res, setWeekly(b)); }
225
- if (u.pathname === "/api/desktop/weekly/run" && req.method === "POST") return json(res, await runWeekly(writeup));
234
+ if (u.pathname === "/api/desktop/weekly/run" && req.method === "POST") { const b = await readBody(req); return json(res, await runWeekly(writeup, { notify: b.notify !== false })); }
226
235
  if (u.pathname === "/api/desktop/autostart" && req.method === "POST") { const b = await readBody(req); return json(res, setAutostart(!!b.on, bin)); }
227
236
  if (u.pathname === "/api/run" && req.method === "POST") { const b = await readBody(req); const cmd = ["week", "standup", "todo"].includes(b.cmd) ? b.cmd : "week"; return json(res, await writeup(cmd)); }
228
237
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
@@ -269,4 +278,4 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
269
278
  startPhone(); // Watch on your phone: the computer listens if it's switched on, the phone asks if it's paired
270
279
  }
271
280
 
272
- export { updateCmd, BROWSER_KEEP, startApp };
281
+ export { updateCmd, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly };
package/ui.mjs CHANGED
@@ -133,6 +133,15 @@ label.check input{width:auto}
133
133
  .scrbox{position:absolute;border:2px solid var(--green);background:rgba(61,220,151,.12);pointer-events:none}
134
134
  .scrbox span{position:absolute;left:0;top:0;font-size:11px;background:var(--green);color:var(--ink);padding:1px 6px;white-space:nowrap}
135
135
  .scrbox.draw{border:2px dashed var(--amber);background:rgba(242,165,65,.14)}
136
+ .board{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-top:12px}
137
+ .bcard{border:1px solid var(--line);border-radius:12px;padding:12px 14px;background:var(--ink2);min-width:0}
138
+ .bcard.has{border-color:#2a6b52}
139
+ .bcard .bh{display:flex;gap:8px;align-items:center}.bcard .bn{color:var(--bone);font-weight:600;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
140
+ .bcard .bc{font-size:26px;font-weight:700;color:var(--faint);margin-top:6px}.bcard.has .bc{color:var(--green)}
141
+ .bcard .bl{font-size:12px;color:var(--faint)}
142
+ .bcard .bi{display:flex;gap:8px;align-items:flex-start;padding:7px 0;border-top:1px solid var(--line);font-size:13px;color:var(--text)}
143
+ .bcard .bi .t{flex:1;min-width:0;overflow-wrap:anywhere}.bcard .bi button{padding:4px 9px;font-size:12px}
144
+ .bcard .bbrief{white-space:pre-line;margin:8px 0;padding:8px 10px;border-left:3px solid var(--green);background:var(--ink3);border-radius:0 8px 8px 0;font-size:13px;color:var(--bone)}
136
145
  @media(max-width:760px){.maprow{flex-direction:column}.detail{width:auto;max-height:none}}
137
146
  @media(max-width:600px){#panel-tasks .row>#newtask,#screenname,#pagesite{flex:1 1 100%!important}.tabs{overflow-x:auto;scrollbar-width:none;padding:0 8px;gap:0}.tabs::-webkit-scrollbar{display:none}.tab{flex:none;padding:9px 11px}header{padding:12px 14px 8px}main{padding:12px}}
138
147
  </style></head><body>
@@ -141,6 +150,7 @@ label.check input{width:auto}
141
150
  <header><span class="dot"></span><span class="brand">Symbiot</span><span class="ver" id="ver"></span><span class="status" id="status">...</span></header>
142
151
  <div class="tabs">
143
152
  <button class="tab active" data-tab="map">Map</button>
153
+ <button class="tab" data-tab="board" id="boardtab">Dashboard</button>
144
154
  <button class="tab" data-tab="drift">Drift</button>
145
155
  <button class="tab" data-tab="week">Week</button>
146
156
  <button class="tab" data-tab="standup">Standup</button>
@@ -180,6 +190,11 @@ label.check input{width:auto}
180
190
  <div id="screenview"></div>
181
191
  </div>
182
192
  </section>
193
+ <section id="panel-board" class="hidden">
194
+ <div class="row"><span class="muted" id="boardsum" style="flex:1">Everything you watch, side by side.</span><select id="boardhours" title="how far back" style="flex:0 0 auto;width:auto"><option value="24">last 24 hours</option><option value="72">last 3 days</option><option value="168">last 7 days</option></select><button class="ghost" id="boardrefresh">Refresh</button></div>
195
+ <div id="boardmsg"></div>
196
+ <div id="board" class="board"></div>
197
+ </section>
183
198
  <section id="panel-run" class="hidden">
184
199
  <div class="row"><button class="act" id="write">Write my <span id="what">week</span></button>
185
200
  <button class="ghost hidden" id="copy">Copy</button>
@@ -293,8 +308,9 @@ var current='map';var mapLoaded=false;var driftLoaded=false;
293
308
  var COLORS={person:'#3DDC97',repo:'#F4F1EA',lang:'#F2A541',tool:'#6bb3ff',agent:'#c58af9',ai:'#5fe3b0',folder:'#b7a98c'};
294
309
  function tabs(){return document.querySelectorAll('.tab');}
295
310
  function setTab(tab){current=tab;tabs().forEach(function(t){t.classList.toggle('active',t.dataset.tab===tab);});
296
- var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==='drift',isAgents=tab==='agents',isRun=(tab==='week'||tab==='standup'||tab==='todo');
311
+ var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==='drift',isAgents=tab==='agents',isBoard=tab==='board',isRun=(tab==='week'||tab==='standup'||tab==='todo');
297
312
  $('panel-map').classList.toggle('hidden',!isMap);
313
+ $('panel-board').classList.toggle('hidden',!isBoard);if(isBoard)loadBoard();
298
314
  $('panel-run').classList.toggle('hidden',!isRun);
299
315
  $('panel-settings').classList.toggle('hidden',!isSet);
300
316
  $('panel-tasks').classList.toggle('hidden',!isTasks);
@@ -734,7 +750,7 @@ var wb=$('scrwatch');if(wb)wb.addEventListener('click',function(){watchUI(s,wb);
734
750
  var WATCH={watches:[],news:[],every:[5,15,30,60]};
735
751
  function watchOf(s){var w=null;(WATCH.watches||[]).forEach(function(x){if(s&&s.page&&x.url===s.page.url)w=x;});return w;}
736
752
  function agoTxt(t){var m=Math.max(0,(Date.now()-t)/60000);return m<1.5?'just now':m<90?Math.round(m)+' min ago':m<2160?Math.round(m/60)+' h ago':Math.round(m/1440)+' days ago';}
737
- function loadWatchUI(){api('/api/watch').then(function(d){if(d&&d.watches)WATCH=d;renderWatch();});}
753
+ function loadWatchUI(){api('/api/watch').then(function(d){if(d&&d.watches)WATCH=d;renderWatch();});loadBoard();}
738
754
  function watchUI(s,b){var w=watchOf(s);
739
755
  if(w){if(typeof confirm==='function'&&!confirm('Stop watching '+w.name+'? What it found goes too.'))return;api('/api/watch/remove',{id:w.id}).then(function(){api('/api/watch').then(function(d){if(d&&d.watches)WATCH=d;renderWatch();renderScreen();});});return;}
740
756
  b.disabled=true;api('/api/watch/add',{screen:s.id,every:15}).then(function(x){b.disabled=false;if(!x||x.error){screenErr((x&&x.error)||'failed');return;}
@@ -758,14 +774,44 @@ var wbr=$('wbrief');if(wbr)wbr.addEventListener('change',function(){api('/api/wa
758
774
  box.querySelectorAll('.wevery').forEach(function(sel){sel.addEventListener('change',function(){var w=ws[+sel.closest('.task').getAttribute('data-i')];api('/api/watch/every',{id:w.id,every:+sel.value}).then(loadWatchUI);});});
759
775
  box.querySelectorAll('.wrm').forEach(function(b){b.addEventListener('click',function(){var w=ws[+b.closest('.task').getAttribute('data-i')];if(typeof confirm==='function'&&!confirm('Stop watching '+w.name+'? What it found goes too.'))return;api('/api/watch/remove',{id:w.id}).then(function(){api('/api/watch').then(function(d){if(d&&d.watches)WATCH=d;renderWatch();renderScreen();});});});});
760
776
  box.querySelectorAll('.wcheck').forEach(function(b){b.addEventListener('click',function(){var w=ws[+b.closest('.task').getAttribute('data-i')];b.disabled=true;b.textContent='Reading…';
761
- api('/api/watch/check',{id:w.id}).then(function(x){b.disabled=false;b.textContent='Check now';if(!x||x.error){screenErr((x&&x.error)||'failed');loadWatchUI();return;}var m=x.busy?"The hidden browser is busy with a map, press or type. It reads "+esc(w.name)+" once that's done.":x.learned!=null?"Read "+esc(x.name)+": learned the "+x.learned+" things listed there now. From here on, anything new is noted.":x["new"]&&x["new"].length?"&#10003; "+x["new"].length+" new on "+esc(x.name)+".":"Read "+esc(x.name)+": nothing new.";
762
- $('screenmsg').innerHTML="<div class='note "+(x["new"]&&x["new"].length?"ok":"muted")+"'>"+m+"</div>";loadWatchUI();}).catch(function(e){b.disabled=false;b.textContent='Check now';screenErr(String((e&&e.message)||e));});});});
777
+ api('/api/watch/check',{id:w.id}).then(function(x){b.disabled=false;b.textContent='Check now';if(!x||x.error){screenErr((x&&x.error)||'failed');loadWatchUI();return;}
778
+ $('screenmsg').innerHTML="<div class='note "+(x["new"]&&x["new"].length?"ok":"muted")+"'>"+watchCheckMsg(w,x)+"</div>";loadWatchUI();}).catch(function(e){b.disabled=false;b.textContent='Check now';screenErr(String((e&&e.message)||e));});});});
763
779
  box.querySelectorAll('.wopen').forEach(function(b){b.addEventListener('click',function(){var n=ns[+b.closest('.task').getAttribute('data-i')];if(n&&/^https?:/.test(n.href))window.open(n.href,'_blank','noopener');});});
764
780
  // Draft a reply (watch.mjs draftReply): handed to your agent, which never sends it
765
781
  box.querySelectorAll('.wdraft').forEach(function(b){b.addEventListener('click',function(){var n=ns[+b.closest('.task').getAttribute('data-i')];b.disabled=true;
766
782
  api('/api/watch/draft',{id:n.id}).then(function(x){b.disabled=false;if(!x||x.error){screenErr((x&&x.error)||'failed');return;}
767
- $('screenmsg').innerHTML="<div class='note ok'>&#10003; Your agent is drafting a reply to it. It opens the email in your inbox through Screens, writes the reply and leaves it in Drafts for you to read and send: it never presses Send. Follow it, and answer anything it asks, in the Agents tab.</div>";loadWatchUI();}).catch(function(e){b.disabled=false;screenErr(String((e&&e.message)||e));});});});
783
+ $('screenmsg').innerHTML="<div class='note ok'>"+DRAFTING+"</div>";loadWatchUI();}).catch(function(e){b.disabled=false;screenErr(String((e&&e.message)||e));});});});
768
784
  var wc=$('wclear');if(wc)wc.addEventListener('click',function(){api('/api/watch/clear',{}).then(loadWatchUI);});}
785
+ // what Check now found, for Watching and the Dashboard
786
+ function watchCheckMsg(w,x){return x.busy?"The hidden browser is busy with a map, press or type. It reads "+esc(w.name)+" once that's done.":x.learned!=null?"Read "+esc(x.name)+": learned the "+x.learned+" things listed there now. From here on, anything new is noted.":x["new"]&&x["new"].length?"&#10003; "+x["new"].length+" new on "+esc(x.name)+".":"Read "+esc(x.name)+": nothing new.";}
787
+ var DRAFTING="&#10003; Your agent is drafting a reply to it. It opens the email in your inbox through Screens, writes the reply and leaves it in Drafts for you to read and send: it never presses Send. Follow it, and answer anything it asks, in the Agents tab.";
788
+ // ---- Dashboard: a card per page you watch (your inbox, GitHub, WhatsApp…), what's new on each ----
789
+ var BOARD=null;var BOARDICON={mail:'&#9993;&#65039;',github:'&#128276;',chat:'&#128172;',page:'&#127760;'};
790
+ function loadBoard(){api('/api/watch/board?hours='+(+$('boardhours').value||24)).then(function(b){if(b&&b.cards){BOARD=b;renderBoard();}});}
791
+ function boardMsg(cls,html){$('boardmsg').innerHTML="<div class='note "+cls+"'>"+html+"</div>";}
792
+ function renderBoard(){var b=BOARD,cs=b.cards,el=$('board');
793
+ var tab=$('boardtab');if(tab)tab.textContent='Dashboard'+(b.total?' · '+b.total:'');
794
+ if(!cs.length){$('boardsum').textContent='Everything you watch, side by side.';
795
+ el.innerHTML="<div style='grid-column:1/-1'><div class='note muted' style='margin-top:0'>Nothing watched yet. Map your inbox, a chat (web.whatsapp.com) or any page under <b>Screens</b> on the Map tab and click <b>Watch</b> on it, or click <b>Watch GitHub</b> there. Each one gets a card here with what's new on it.</div><div class='row' style='margin-top:8px'><button class='ghost' id='boardgo'>Go to Screens</button></div></div>";
796
+ $('boardgo').addEventListener('click',function(){setTab('map');var w=$('watchbox');if(w&&w.scrollIntoView)w.scrollIntoView({block:'center'});});return;}
797
+ var when=b.hours===24?'since yesterday':b.hours===72?'in the last 3 days':'in the last 7 days';
798
+ var waiting=cs.filter(function(c){return c.count;}).map(function(c){return c.label+(c.source==='page'?' on '+c.name:'');});
799
+ $('boardsum').innerHTML=waiting.length?"<b style='color:var(--bone)'>Waiting on you "+when+":</b> "+esc(waiting.join(', ')):"Nothing new "+when+" on the "+cs.length+" thing"+(cs.length===1?"":"s")+" you watch.";
800
+ el.innerHTML=cs.map(function(c,i){
801
+ return "<div class='bcard"+(c.count?" has":"")+"' data-i='"+i+"'><div class='bh'><span>"+(BOARDICON[c.source]||BOARDICON.page)+"</span><span class='bn' title='"+esc(c.url)+"'>"+esc(c.name)+"</span><button class='ghost bcheck' title='read it now' style='padding:4px 9px;font-size:12px'>Check now</button></div>"
802
+ +"<div class='bc'>"+c.count+"</div><div class='bl'>"+esc(c.count?c.label.replace(/^\\d+ /,''):'nothing new')+" &middot; "+(c.checked?"read "+agoTxt(c.checked):c.last?"tried "+agoTxt(c.last):"first read within a minute")+(c.via==='gh'?" &middot; through gh":"")+"</div>"
803
+ +(c.error?"<div class='note err'>"+esc(c.error)+"</div>":"")
804
+ +(c.brief?"<div class='bbrief'>"+esc(c.brief.text)+"</div>":"")
805
+ +c.items.map(function(n,j){return "<div class='bi' data-j='"+j+"'><span class='t'>"+esc(n.text)+" <span class='muted' style='font-size:11px'>"+agoTxt(n.ts)+"</span></span>"+(n.href?"<button class='ghost bopen' title='open it in your browser'>Open</button>":"")+(n.mail?"<button class='ghost bdraft' title='your coding agent writes a reply and leaves it in Drafts. It never presses Send'>"+(n.drafted?"Drafted &middot; again":"Draft a reply")+"</button>":"")+"</div>";}).join('')
806
+ +(c.count>c.items.length?"<div class='bl' style='margin-top:6px'>&hellip;and "+(c.count-c.items.length)+" more</div>":"")+"</div>";}).join('');
807
+ function card(btn){return cs[+btn.closest('.bcard').getAttribute('data-i')];}
808
+ function item(btn){return card(btn).items[+btn.closest('.bi').getAttribute('data-j')];}
809
+ el.querySelectorAll('.bcheck').forEach(function(btn){btn.addEventListener('click',function(){var c=card(btn);btn.disabled=true;btn.textContent='Reading…';
810
+ api('/api/watch/check',{id:c.id}).then(function(x){if(!x||x.error)boardMsg('err',esc((x&&x.error)||'failed'));else boardMsg(x["new"]&&x["new"].length?'ok':'muted',watchCheckMsg(c,x));loadWatchUI();}).catch(function(e){btn.disabled=false;btn.textContent='Check now';boardMsg('err',esc(String((e&&e.message)||e)));});});});
811
+ el.querySelectorAll('.bopen').forEach(function(btn){btn.addEventListener('click',function(){var n=item(btn);if(n&&/^https?:/.test(n.href))window.open(n.href,'_blank','noopener');});});
812
+ el.querySelectorAll('.bdraft').forEach(function(btn){btn.addEventListener('click',function(){var n=item(btn);btn.disabled=true;
813
+ api('/api/watch/draft',{id:n.id}).then(function(x){btn.disabled=false;if(!x||x.error){boardMsg('err',esc((x&&x.error)||'failed'));return;}boardMsg('ok',DRAFTING);loadWatchUI();}).catch(function(e){btn.disabled=false;boardMsg('err',esc(String((e&&e.message)||e)));});});});}
814
+ $('boardrefresh').addEventListener('click',loadBoard);$('boardhours').addEventListener('change',loadBoard);
769
815
  // A web page, mapped by itself in the hidden browser (headless.mjs): no capture,
770
816
  // no dragging. It takes a few seconds, so the button says so meanwhile.
771
817
  function mapUI(site,b){site=(site||'').trim();if(!site){screenErr('Type a site to map first: gmail, github.com/pulls or a web address.');return;}b.disabled=true;
package/watch.mjs CHANGED
@@ -187,6 +187,7 @@ function kindOf(url) {
187
187
  const h = hostOf(url);
188
188
  if (isGitHubInbox(url)) return ["GitHub notification", "GitHub notifications"];
189
189
  if (/^(mail|webmail)\.|^outlook\.(live|office|office365)\.com$/.test(h)) return ["email", "emails"];
190
+ if (h === "web.whatsapp.com") return ["WhatsApp message", "WhatsApp messages"];
190
191
  return null;
191
192
  }
192
193
  // What's new on each page you watch in the last `hours`: [{ name, count, label,
@@ -203,6 +204,23 @@ function waitingOn(hours = 24, now = Date.now()) {
203
204
  });
204
205
  }
205
206
 
207
+ // ---- the Dashboard: one card per page you watch ----------------------------------
208
+ // Each watch with what it found in the last `hours`: its source (mail, github,
209
+ // chat, page), how many ("3 emails"), the newest few and its latest brief. Read
210
+ // from all of watch.json, so a busy GitHub can't push your mail off the board the
211
+ // way it can off the 50 newest under Watching.
212
+ const sourceOf = (url) => (isGitHubInbox(url) ? "github" : isMail(url) ? "mail" : hostOf(url) === "web.whatsapp.com" ? "chat" : "page");
213
+ function watchBoard(hours = 24, now = Date.now()) {
214
+ const d = loadWatch(), since = now - hours * 3600000;
215
+ const cards = d.watches.map((w) => {
216
+ const recent = d.news.filter((n) => n.watch === w.id && n.ts >= since), k = kindOf(w.url), mail = isMail(w.url), count = recent.length;
217
+ const b = d.briefs.find((x) => x.watch === w.id && x.ts >= since);
218
+ return { ...view(w), source: sourceOf(w.url), count, label: k ? `${count} ${k[count === 1 ? 0 : 1]}` : `${count} new`,
219
+ items: recent.slice(0, 8).map((n) => (mail ? { ...n, mail: true } : n)), ...(b ? { brief: { text: b.text, ts: b.ts, count: b.count } } : {}) };
220
+ });
221
+ return { hours, total: cards.reduce((s, c) => s + c.count, 0), cards, brief: briefOn() };
222
+ }
223
+
206
224
  // A batch of news as one notification: how many and where, then the brief if
207
225
  // there is one, else the first few. The phone (phone.mjs) shows the same.
208
226
  function newsNotice(news, name, brief = "") {
@@ -349,4 +367,4 @@ function startWatches(opts = {}) {
349
367
  return () => { clearTimeout(first); clearInterval(every); };
350
368
  }
351
369
 
352
- export { WATCH_FILE, EVERY, GITHUB_INBOX, DRAFTS_DIR, itemsOf, itemKey, newItems, remember, isGitHubInbox, githubItems, readGitHub, setBrief, briefOf, newsNotice, watchState, addWatch, setEvery, removeWatch, clearNews, newsSince, newsAfter, waitingOn, draftsUrl, draftBrief, draftReply, checkWatch, dueWatches, startWatches };
370
+ export { WATCH_FILE, EVERY, GITHUB_INBOX, DRAFTS_DIR, itemsOf, itemKey, newItems, remember, isGitHubInbox, githubItems, readGitHub, setBrief, briefOf, newsNotice, watchState, addWatch, setEvery, removeWatch, clearNews, newsSince, newsAfter, waitingOn, watchBoard, isMail, draftsUrl, draftBrief, draftReply, checkWatch, dueWatches, startWatches };