symbiot 0.32.0 → 0.34.0

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.
Files changed (5) hide show
  1. package/README.md +146 -52
  2. package/index.mjs +186 -28
  3. package/mail.mjs +155 -0
  4. package/package.json +3 -2
  5. package/ui.mjs +92 -6
package/README.md CHANGED
@@ -12,16 +12,17 @@ symbiot your last 7 days, written up (same as: symbiot week)
12
12
  symbiot standup yesterday + today, for standup
13
13
  symbiot todo what's still on your plate
14
14
  symbiot app open the visual app in your browser
15
- symbiot drift what's out of sync / at risk across your repos
16
15
  symbiot push write tasks into each repo for your coding agent
17
- symbiot models recommend AI models for your hardware
18
- symbiot setup-local set up a free local model (Ollama), one command
16
+ symbiot drift what's out of sync / at risk across your repos
19
17
  symbiot login connect it to an AI (once)
20
18
  symbiot whoami show how it's connected
21
19
  symbiot logout forget saved credentials
22
20
  symbiot help
23
21
  ```
24
22
 
23
+ A few newer extras (email, CI checks, local-model setup) are
24
+ [experimental](#experimental).
25
+
25
26
  ## Install
26
27
 
27
28
  ```bash
@@ -53,7 +54,7 @@ Tabs:
53
54
  **no AI key**; reviews and suggestions use your chosen model. While the scan runs
54
55
  the Map shows its progress.
55
56
  - **Drift** — the [`symbiot drift`](#whats-out-of-sync-symbiot-drift) report, with
56
- "fetch latest" and "check CI" toggles.
57
+ a "fetch latest" toggle (and an [experimental](#experimental) "check CI").
57
58
  - **Week / Standup / Todo** — the write-ups (these use your chosen AI).
58
59
  - **Tasks** — a checklist, grouped by kind (Fixes, Tests & CI, Docs, …). Filter by
59
60
  type or repo, then **Send to repos** to hand just those to your agent (see
@@ -68,8 +69,11 @@ Tabs:
68
69
  - **Agents** — every agent run Symbiot has started (and local-model downloads):
69
70
  live status, elapsed time, exit code, the tail of its output, and **what it
70
71
  did** — files changed and commits made, read from git, whichever agent it was.
71
- - **Settings** — your AI, the folders to scan, your agent command, model
72
- recommendations and a one-click local model.
72
+ When an agent leaves **questions, options or ideas** for you, they show up on its
73
+ block (see [Questions from your agent](#questions-from-your-agent)).
74
+ - **Settings** — your AI, the folders to scan and your agent command, plus the
75
+ [experimental](#experimental) email, model recommendations and one-click local
76
+ model.
73
77
 
74
78
  **Stays current by itself.** The app checks npm for a newer Symbiot every couple of
75
79
  minutes (and whenever you come back to the window); the version you're on is shown
@@ -78,7 +82,10 @@ version, relaunches the app on the same address, and the open window reloads its
78
82
  onto it. Restart `symbiot app` yourself and the open window reconnects the same way.
79
83
 
80
84
  The app listens on port **7391** so its address survives restarts; set
81
- `SYMBIOT_PORT` to use another (it falls back to a free port if that one is busy).
85
+ `SYMBIOT_PORT` to use another. **One app at a time:** running `symbiot app` while
86
+ it's already running just opens the existing window instead of starting a second
87
+ copy (`SYMBIOT_FORCE_NEW=1` starts one anyway). If something else holds the port,
88
+ it falls back to a free one.
82
89
  It works on **Linux, macOS and Windows**; with no Chromium-family browser to open
83
90
  the chrome-less window, it uses your default browser.
84
91
 
@@ -126,7 +133,7 @@ a template — `{dir}` is the repo path, `{prompt}` is the instruction to read
126
133
  `TASKS.md` — so it works with any agent or editor:
127
134
 
128
135
  ```
129
- claude -p "{prompt}" --permission-mode acceptEdits
136
+ claude -p "{prompt}" --permission-mode acceptEdits --allowedTools "Bash(npm test:*)" "Bash(node:*)"
130
137
  aider --message "{prompt}" --yes
131
138
  code {dir}
132
139
  ```
@@ -135,24 +142,55 @@ Settings shows **one-click presets** for what's installed on your machine. A
135
142
  preset only fills in the command box; the saved command is what runs.
136
143
 
137
144
  - **Agents** (all make changes, ready for your review): Claude Code, Codex
138
- (OpenAI), Aider, Cursor agent, Gemini CLI.
145
+ (OpenAI), Aider, Cursor agent, Gemini CLI. The Claude preset may also run
146
+ `npm test` and `node`, so it can check its own work; a command saved from the
147
+ older preset is upgraded automatically, and an edited one is left alone.
139
148
  - **Orca IDE** (any OS): opens the repo in Orca — either just the repo, to use
140
149
  Orca's own agent, or with Claude running in a new tab. It launches Orca if it's
141
150
  closed and waits for it to be ready first.
142
- - **Editors:** VS Code, Cursor, Windsurf, Zed, Sublime Text, IntelliJ IDEA, Neovim
143
- (on macOS, also found as `.app` bundles when the CLI isn't on your PATH).
151
+ - **Editors — opens only, no review:** VS Code, Cursor, Windsurf, Zed, Sublime
152
+ Text, IntelliJ IDEA, Neovim (on macOS, also found as `.app` bundles when the CLI
153
+ isn't on your PATH). They just open the repo. Nothing comes back for review unless
154
+ you run an agent there yourself, and Neovim needs a terminal to open in.
144
155
 
145
156
  Each run is logged to `.symbiot/agent.log` in the repo and shown live in the
146
157
  **Agents** tab. The command is saved as `agentCmd` in
147
158
  `~/.config/symbiot/config.json` — it's your command, Symbiot only fills in
148
159
  `{dir}` and `{prompt}`. A command saved from an older preset keeps working as-is.
149
160
 
161
+ ### Questions from your agent
162
+
163
+ An agent working in your terminal or IDE stops to ask you things: which way to go,
164
+ whether to do something risky, ideas it had along the way. A handed-off run can't
165
+ do that, because it runs unattended. So the brief gives **every** agent (Claude,
166
+ Codex, Aider, Gemini, Cursor, whatever you run) a way to ask anyway. It writes
167
+ **`.symbiot/QUESTIONS.md`**:
168
+
169
+ ```markdown
170
+ ## Questions
171
+ ### Keep the old config format working?
172
+ Reading both costs about 40 lines.
173
+ - Yes, read both (recommended)
174
+ - No, migrate once and drop it
175
+
176
+ ## Suggestions
177
+ - Add a --json flag to drift
178
+ ```
179
+
180
+ Up to five questions show on that agent's block in the **Agents** tab, each with
181
+ its options and room for your own answer, alongside its ideas. **Send answers &
182
+ continue** appends your answers to `.symbiot/ANSWERS.md` and runs your agent
183
+ command again so the agent carries on with them. **Save only** keeps them for the
184
+ next run. **+ task** adds an idea to your Tasks for that repo. The agent is told to
185
+ keep going with everything that doesn't depend on an answer, and to ask instead of
186
+ doing anything destructive. Both files live in `.symbiot/`, so they're never
187
+ committed.
188
+
150
189
  ## What's out of sync? `symbiot drift`
151
190
 
152
191
  ```bash
153
192
  symbiot drift # local git facts only — fast, no network
154
193
  symbiot drift --fetch # fetch from origin first, so "behind" is current
155
- symbiot drift --ci # also check GitHub Actions state (needs gh)
156
194
  ```
157
195
 
158
196
  A **deterministic** report of what's out of sync, stuck, or at risk across your
@@ -163,10 +201,9 @@ not new work), **uncommitted work**, **behind upstream**, **multiple worktrees**
163
201
  branch** (the "merged but main didn't move" trap — split into *likely never
164
202
  landed*, when files the PR added are missing from main, and *probably re-done*,
165
203
  when they're all there), and — if you configure it — **production running code
166
- that isn't on your default branch**. With `--ci` it tells a real failing run apart
167
- from **CI that isn't running at all** (jobs never started — usually a billing or
168
- spending limit), quoting GitHub's reason when it can. Works on local-only repos
169
- too: without a remote it compares against your local default branch.
204
+ that isn't on your default branch**. Works on local-only repos too: without a
205
+ remote it compares against your local default branch. (CI checks with `--ci` are
206
+ [experimental](#ci-status-symbiot-drift---ci).)
170
207
 
171
208
  For the production check, add a per-repo deploy command to
172
209
  `~/.config/symbiot/deploys.json` (read only from your own config, never from a
@@ -174,31 +211,6 @@ repo), keyed by repo path or folder name, that prints the deployed commit sha:
174
211
  `{ "/path/to/repo": "ssh prod cat ~/app/.deployed-sha" }`. Also the **Drift** tab
175
212
  in the app.
176
213
 
177
- ## Which model? Ask your machine
178
-
179
- ```bash
180
- symbiot models
181
- ```
182
-
183
- Reads your RAM / CPU / GPU and recommends **local models by tier** (min / med / max,
184
- marking which fit your RAM) to run free & private via [Ollama](https://ollama.com),
185
- plus **paid** options (Claude / OpenAI / Gemini, cheap → top). Also available as
186
- a button in the app's Settings.
187
-
188
- ### A free local model in one command: `symbiot setup-local`
189
-
190
- ```bash
191
- symbiot setup-local # the best model for your RAM
192
- symbiot setup-local --model llama3.2:3b # or pick one
193
- ```
194
-
195
- Starts Ollama if it isn't running, downloads the model (with progress), and
196
- switches Symbiot over to it — free, private, nothing leaves your machine. If
197
- Ollama isn't installed yet it prints the one-line install for your OS (Homebrew on
198
- macOS, winget on Windows, the official script on Linux) and you re-run it after.
199
- Also the **Set up a free local model** button in Settings, where the download shows
200
- in the Agents tab and Symbiot switches over when it finishes.
201
-
202
214
  ## Connect it (once)
203
215
 
204
216
  Pick the AI you want it to write with:
@@ -266,11 +278,14 @@ instead of hanging. The CLI shows scan progress on one line as it goes.
266
278
  ## What it reads, and what it doesn't
267
279
 
268
280
  - **Reads:** your local git — commit messages and changed-file names, plus
269
- `TODO`/`FIXME` markers and uncommitted changes for `todo`. All local.
281
+ `TODO`/`FIXME` markers and uncommitted changes for `todo`. If you switch
282
+ [email](#your-sent-email-without-an-api-symbiot-mail) on, it also reads the headers
283
+ of mail you sent (subject, recipients, date). All local.
270
284
  - **Sends to the AI:** commit messages and dates, changed-file **names**, the
271
285
  folder structure, `TODO`/`FIXME` lines, and (for a repo review) an excerpt of
272
- the README and any `CLAUDE.md`/`AGENTS.md` conventions. It does **not** send
273
- whole source files. With a **local Ollama model, nothing leaves your machine.**
286
+ the README and any `CLAUDE.md`/`AGENTS.md` conventions. With email on, it also
287
+ sends the subjects and recipient names of mail you sent. It does **not** send
288
+ whole source files or any email body. With a **local Ollama model, nothing leaves your machine.**
274
289
  - **Shows its work:** every report ends with a footer — path, branch, how many
275
290
  commits matched you (e.g. "1090 of 1101"), the README's age, and the
276
291
  working-tree state — so you can see exactly what it read.
@@ -278,10 +293,70 @@ instead of hanging. The CLI shows scan progress on one line as it goes.
278
293
  - **Runs only what you set:** the agent command is yours, and deploy commands are
279
294
  read only from your own `~/.config/symbiot/`, never from a repo.
280
295
 
296
+ ## Experimental
297
+
298
+ These work and are tested, but are newer or depend on things Symbiot can't fully
299
+ check, so they may change. Everything above works without them.
300
+
301
+ ### Your sent email, without an API: `symbiot mail`
302
+
303
+ ```bash
304
+ symbiot mail # what mail it can read, and what you sent this week
305
+ symbiot mail --on # use it in week / standup (--off to stop)
306
+ symbiot mail --add ~/Takeout/Mail/All\ mail.mbox # an export, or any mail folder
307
+ ```
308
+
309
+ Week and Standup can include **what you sent**. It needs no Gmail or Outlook API,
310
+ OAuth, app registration or password: Symbiot reads the mail a desktop client
311
+ already keeps on your computer, so anyone can link theirs the same way.
312
+ **Thunderbird** (including Snap and Flatpak), **Apple Mail**, **Evolution**,
313
+ **KMail**, and **mutt/neomutt** or any `~/Maildir` / `~/mail` are found
314
+ automatically, and only their **Sent** folders are read. Webmail only? Add an
315
+ export: Google Takeout gives you an `.mbox`, and Symbiot keeps just the messages
316
+ with Gmail's *Sent* label (or from one of your addresses). Any `.mbox` file or
317
+ folder of `.eml` files works too.
318
+
319
+ It reads **headers only** (date, recipients, subject), never a message body, and
320
+ it's **off until you switch it on** in Settings (*Email*) or with
321
+ `symbiot mail --on`. **Preview** shows exactly what a write-up would see. Why it's
322
+ experimental: the tests cover mbox and Maildir, but it hasn't been tried against
323
+ real Apple Mail, Evolution or KMail stores. On macOS, reading Apple Mail needs Full
324
+ Disk Access for your terminal. Outlook for Windows (`.pst`) isn't supported:
325
+ export to `.mbox` or use Thunderbird.
326
+
327
+ ### CI status: `symbiot drift --ci`
328
+
329
+ ```bash
330
+ symbiot drift --ci # also check GitHub Actions state (needs gh)
331
+ ```
332
+
333
+ Adds CI to the drift report, telling a real failing run apart from **CI that isn't
334
+ running at all** (jobs never started, usually a billing or spending limit) and
335
+ quoting GitHub's reason when it can. Also the **check CI** toggle in the Drift tab.
336
+ Why it's experimental: it needs the GitHub CLI (`gh`), only covers GitHub Actions,
337
+ and spotting "not running" is a heuristic.
338
+
339
+ ### Local models: `symbiot models` and `symbiot setup-local`
340
+
341
+ ```bash
342
+ symbiot models # recommend models for this machine
343
+ symbiot setup-local # the best local model for your RAM
344
+ symbiot setup-local --model llama3.2:3b # or pick one
345
+ ```
346
+
347
+ `models` reads your RAM / CPU / GPU and recommends **local models by tier** (min /
348
+ med / max, marking which fit your RAM) to run free and private via
349
+ [Ollama](https://ollama.com), plus **paid** options (Claude / OpenAI / Gemini,
350
+ cheap → top). `setup-local` starts Ollama if it isn't running, downloads the model
351
+ with progress, and switches Symbiot over to it. If Ollama isn't installed it prints
352
+ the one-line install for your OS and you re-run it after. Both are buttons in
353
+ Settings, where the download shows in the Agents tab. You can always pick a model
354
+ yourself with `symbiot login`.
355
+
281
356
  ## Config
282
357
 
283
358
  Everything lives in `~/.config/symbiot/`: `config.json` (your AI, `scanRoots`,
284
- `agentCmd`; readable only by you), `tasks.json` (your tasks),
359
+ `agentCmd`, `mail`; readable only by you), `tasks.json` (your tasks),
285
360
  `deploys.json` (optional, for drift's production check) and `rules.md` (optional
286
361
  conventions every repo review must respect, alongside each repo's own
287
362
  `CLAUDE.md` / `AGENTS.md` / `CONTRIBUTING.md`).
@@ -290,6 +365,7 @@ conventions every repo review must respect, alongside each repo's own
290
365
  `claude-haiku-4-5`, `gemini-1.5-pro`).
291
366
  - `SYMBIOT_SCAN_TIMEOUT` — the scan time limit, in seconds (default 60).
292
367
  - `SYMBIOT_PORT` — the app's port (default 7391).
368
+ - `SYMBIOT_FORCE_NEW=1` — start a second app even if one is already running.
293
369
  - `symbiot whoami` shows the active provider, model, and where the credential
294
370
  came from. `symbiot logout` forgets saved credentials.
295
371
 
@@ -299,26 +375,44 @@ conventions every repo review must respect, alongside each repo's own
299
375
  npm test
300
376
  ```
301
377
 
302
- Runs four suites, all against throwaway repos and an isolated `HOME`:
378
+ Runs five suites, all against throwaway repos, an isolated `HOME` and (for the
379
+ update check) a fake npm registry:
303
380
 
304
381
  - `test/load.mjs` — the shipped files parse and load: `node --check` on each
305
382
  module, `ui.mjs` imported on its own with its page's JavaScript parsed, every
306
383
  local import listed in package.json `"files"`, and the `bin` entry point shipped.
307
384
  - `test/fixtures.mjs` — accuracy fixtures: the facts Symbiot collects (identity
308
385
  matching, stale checkouts, worktrees, drift) and the `symbiot drift` report as
309
- printed, the scan time limit, the agent handoff, and the review → approve cycle.
386
+ printed, the scan time limit, the agent handoff, review → send back → approve →
387
+ ship (including a repo that gitignores `.symbiot/`), agent questions, mail
388
+ ingestion (`symbiot mail` end to end), and the update command.
310
389
  - `test/smoke.mjs` — boots `symbiot app`, runs the page's own JavaScript against
311
390
  a fake DOM (every tab, every button), hits every endpoint the UI calls with the
312
- method the UI uses, and checks POST-only endpoints refuse GET and `/api` needs
313
- the token.
391
+ method the UI uses, checks POST-only endpoints refuse GET and `/api` needs the
392
+ token, and checks the update banner can't loop.
393
+ - `test/app.mjs` — the app's lifecycle: a second `symbiot app` reuses the running
394
+ one (and `SYMBIOT_FORCE_NEW` / a foreign server on the port don't), and the
395
+ update check only offers a higher npm version.
314
396
  - `test/install.mjs` — runs the CLI through a bin symlink, then `npm pack` +
315
397
  global install into a temp prefix: `symbiot help`, every shipped file present,
316
398
  and the installed `symbiot app` serving its page. Needs npm registry access for
317
399
  dependencies; `SYMBIOT_SKIP_INSTALL_TEST=1` skips that part.
318
400
 
319
- `SYMBIOT_NO_OPEN=1` stops `symbiot app` opening a window (the tests set it).
320
- Releases publish from a version tag via `.github/workflows/publish.yml`, and only
321
- after the full suite passes in CI.
401
+ `SYMBIOT_NO_OPEN=1` stops `symbiot app` opening a window (the tests set it), and
402
+ `SYMBIOT_REGISTRY` points its update check at another registry. CI
403
+ (`.github/workflows/ci.yml`) runs the whole suite on every pull request.
404
+
405
+ **Releasing.** Bump the version in the pull request, merge it into `main`, then tag
406
+ `main`'s commit:
407
+
408
+ ```bash
409
+ git checkout main && git pull
410
+ git tag v$(node -p "require('./package.json').version") && git push origin --tags
411
+ ```
412
+
413
+ `.github/workflows/publish.yml` publishes from that tag only if it's on `main`,
414
+ matches `package.json`, and the full suite passes. A tag on any other branch is
415
+ refused, so what's on npm is always what's on `main`.
322
416
 
323
417
  ---
324
418
 
package/index.mjs CHANGED
@@ -9,7 +9,8 @@
9
9
  // symbiot week your week, written up (default)
10
10
  // symbiot standup yesterday + today, for standup
11
11
  // symbiot todo what's still on your plate
12
- // symbiot login connect it to an AI (once)
12
+ // symbiot mail add the mail you sent to write-ups (local, no API)
13
+ // symbiot login connect it to an AI (once)
13
14
  // symbiot whoami show how it's connected
14
15
  // symbiot help
15
16
  //
@@ -27,6 +28,7 @@ import { createServer } from "node:http";
27
28
  import { randomBytes } from "node:crypto";
28
29
  import { fileURLToPath, pathToFileURL } from "node:url";
29
30
  import { EMBEDDED_UI } from "./ui.mjs";
31
+ import { detectMailSources, mailActivity } from "./mail.mjs";
30
32
 
31
33
  const HERE = fileURLToPath(new URL(".", import.meta.url));
32
34
  let VERSION = "0"; try { VERSION = JSON.parse(readFileSync(join(HERE, "package.json"), "utf8")).version; } catch {}
@@ -62,7 +64,17 @@ let LATEST_VERSION = ""; // newest symbiot on npm, checked in the background
62
64
  // a.b.c numeric compare: only a HIGHER npm version is an update (a local build
63
65
  // ahead of npm must not be offered a "newer" older one)
64
66
  function semverGt(a, b) { const p = (v) => String(v || "").replace(/^v/, "").split(/[.-]/).slice(0, 3).map((n) => parseInt(n, 10) || 0); const x = p(a), y = p(b); for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i]; return false; }
65
- async function checkLatest() { try { const r = await fetch("https://registry.npmjs.org/symbiot"); if (!r.ok) return; const j = await r.json(); LATEST_VERSION = (j["dist-tags"] && j["dist-tags"].latest) || ""; } catch {} }
67
+ // SYMBIOT_REGISTRY points the check at another registry (the tests use a fake one)
68
+ const REGISTRY = (process.env.SYMBIOT_REGISTRY || "https://registry.npmjs.org").replace(/\/+$/, "");
69
+ async function checkLatest() { try { const r = await fetch(REGISTRY + "/symbiot"); if (!r.ok) return; const j = await r.json(); LATEST_VERSION = (j["dist-tags"] && j["dist-tags"].latest) || ""; } catch {} }
70
+ // The in-app update installs the EXACT newest version (not the `latest` tag, which
71
+ // npm's cache/propagation can resolve stale — that caused an update loop where the
72
+ // install kept re-fetching the same old version). --prefer-online skips a stale
73
+ // cached packument.
74
+ function updateCmd(latest, current, platform = process.platform) {
75
+ const target = latest && semverGt(latest, current) ? latest : "latest";
76
+ return { target, cmd: (platform === "win32" ? "npm i -g " : "npm install -g ") + "symbiot@" + target + " --prefer-online" };
77
+ }
66
78
 
67
79
  // ---- colour + spinner ------------------------------------------------------
68
80
  const c = PLAIN
@@ -267,6 +279,10 @@ function buildTasksMd(name, ctx, list) {
267
279
  const keys = Object.keys(byType).sort((a, b) => TASK_ORDER.indexOf(a) - TASK_ORDER.indexOf(b));
268
280
  for (const ty of keys) { L.push(`### ${ty}`); for (const t of byType[ty]) L.push(`- [ ] ${t.text}`); L.push(""); }
269
281
  L.push("## When you finish an item", "- Tick it here (`- [x]`) as soon as it's done — that's how it reaches review. Ticking doesn't archive it: the user approves it in Symbiot, which commits it on a branch and opens a PR.", "- Leave your changes **uncommitted**, and don't tick anything you didn't finish or couldn't verify.", "");
282
+ L.push("## If you need a decision, or have ideas", "You may be running unattended, so you can't ask in chat. Write `.symbiot/QUESTIONS.md` instead: Symbiot shows it to the user on your block in its Agents tab, and their answers come back in `.symbiot/ANSWERS.md` (read that first if it exists).",
283
+ `- At most ${QUESTIONS_MAX} questions, under a \`## Questions\` heading. Each is a \`### \` heading, then an optional line of context, then 2–4 options as \`- \` bullets (put the one you recommend first).`,
284
+ "- Ideas, options or follow-ups outside these tasks go under `## Suggestions` as `- ` bullets — the user can add them to their tasks in one click.",
285
+ "- Carry on with everything that doesn't depend on an answer, and don't tick an item that does. Ask there rather than doing anything destructive.", "");
270
286
  L.push("---", 'To action these, tell your coding agent: "Read `.symbiot/TASKS.md` and implement the unchecked items in this repo, using the context above. Tick each item as you finish it and leave changes uncommitted for review. Confirm with me before anything destructive."');
271
287
  return L.join("\n") + "\n";
272
288
  }
@@ -746,8 +762,8 @@ const shSingle = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
746
762
  const escDq = (s) => String(s).replace(/[\\"$`]/g, "\\$&");
747
763
  // Plain words only: in the Orca preset this passes through two shells, so no
748
764
  // backticks or $ (escDq covers one level).
749
- const HANDOFF_PROMPT = "Read .symbiot/TASKS.md and implement the unchecked items in this repo. Tick each item [x] in that file as you finish it and leave your changes uncommitted, so they can be reviewed and approved. Confirm before anything destructive.";
750
- function handoffCmd() { const cfg = loadConfig(); return migrateOrcaCmd(cfg.agentCmd) || (cfg.ide ? `${cfg.ide} {dir}` : ""); } // ide = legacy
765
+ const HANDOFF_PROMPT = "Read .symbiot/TASKS.md and implement the unchecked items in this repo. Tick each item [x] in that file as you finish it and leave your changes uncommitted, so they can be reviewed and approved. If you need a decision, or have ideas or options for the user, write them to .symbiot/QUESTIONS.md as TASKS.md explains, and read .symbiot/ANSWERS.md first if it exists. Confirm before anything destructive.";
766
+ function handoffCmd() { const cfg = loadConfig(); return migrateOrcaCmd(migrateClaudeCmd(cfg.agentCmd)) || (cfg.ide ? `${cfg.ide} {dir}` : ""); } // ide = legacy
751
767
  // Save the template ("" clears it). Either way the legacy `ide` key goes.
752
768
  function setHandoffCmd(cmd) {
753
769
  const cfg = loadConfig(); const v = String(cmd || "").trim();
@@ -766,8 +782,13 @@ const IDE_LIST = [["code", "VS Code", "Visual Studio Code"], ["cursor", "Cursor"
766
782
  // [cmd, label, template]. Only agents that leave changes to review: a handoff
767
783
  // runs unattended, so a "plan only" run can't ask anything and leaves nothing to
768
784
  // approve — that preset was dropped (a saved one still runs as-is).
785
+ // Claude: acceptEdits alone still blocks every shell command, so a run could
786
+ // never run its own tests and had to leave test work unticked. Allow just the
787
+ // test runner and node.
788
+ const CLAUDE_CMD = 'claude -p "{prompt}" --permission-mode acceptEdits --allowedTools "Bash(npm test:*)" "Bash(node:*)"';
789
+ const CLAUDE_CMD_OLD = 'claude -p "{prompt}" --permission-mode acceptEdits'; // ≤0.33
769
790
  const AGENT_LIST = [
770
- ["claude", "Claude Code — make changes", 'claude -p "{prompt}" --permission-mode acceptEdits'],
791
+ ["claude", "Claude Code — make changes", CLAUDE_CMD],
771
792
  ["codex", "Codex (OpenAI/GPT) — make changes", 'codex exec --full-auto "{prompt}"'],
772
793
  ["aider", "Aider — make changes", 'aider --message "{prompt}" --yes'],
773
794
  ["gemini", "Gemini — make changes", 'gemini --yolo -p "{prompt}"'],
@@ -829,6 +850,13 @@ function migrateOrcaCmd(cmd) {
829
850
  if (rebuilt !== cmd) { try { const cfg = loadConfig(); if (cfg.agentCmd === cmd) { cfg.agentCmd = rebuilt; saveConfig(cfg); } } catch {} }
830
851
  return rebuilt;
831
852
  }
853
+ // Upgrade a saved Claude preset from ≤0.33 to the current one. Exact match only:
854
+ // a command the user edited is theirs and runs as-is.
855
+ function migrateClaudeCmd(cmd) {
856
+ if (typeof cmd !== "string" || cmd.trim() !== CLAUDE_CMD_OLD) return cmd;
857
+ try { const cfg = loadConfig(); if (cfg.agentCmd === cmd) { cfg.agentCmd = CLAUDE_CMD; saveConfig(cfg); } } catch {}
858
+ return CLAUDE_CMD;
859
+ }
832
860
  // Cross-platform detection for the presets (and gh / the app's browser).
833
861
  // "Is this command available?": command -v on posix, where on win.
834
862
  function hasCmd(cmd) { try { return !!sh(process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`).trim(); } catch { return false; } }
@@ -887,6 +915,81 @@ function agentChanges(path, startedAt) {
887
915
  return { dirty, stat, commits };
888
916
  } catch { return { dirty: 0, stat: "", commits: [] }; }
889
917
  }
918
+ // ---- agent questions: decisions, options and ideas, from ANY agent ---------
919
+ // A handoff runs unattended (claude -p, codex exec, aider --message, …), so the
920
+ // agent can't stop and ask in chat. TASKS.md tells it to write
921
+ // .symbiot/QUESTIONS.md instead — questions with options, plus ideas — which
922
+ // the Agents tab shows on that agent's block. The user's answers are appended to
923
+ // .symbiot/ANSWERS.md for the next run to read. Plain files, so it works the
924
+ // same whichever model or tool the agent is.
925
+ const QUESTIONS_MAX = 5;
926
+ const qKey = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ");
927
+ const readSymbiot = (path, f) => { try { return readFileSync(join(path, ".symbiot", f), "utf8"); } catch { return ""; } };
928
+ // "## Questions" → "### question", context lines, "- option" bullets;
929
+ // "## Suggestions" (or Ideas / Follow-ups) → "- idea" bullets. Forgiving: a bare
930
+ // bullet under Questions is a question with no options.
931
+ function parseQuestions(md) {
932
+ const questions = [], suggestions = []; let sec = "q", cur = null;
933
+ const clip = (s) => String(s).trim().slice(0, 300);
934
+ for (const raw of String(md || "").split(/\r?\n/)) {
935
+ const l = raw.trim(); if (!l) continue;
936
+ let m;
937
+ if ((m = l.match(/^###\s+(.+)$/))) {
938
+ if (sec === "s") { suggestions.push(clip(m[1])); cur = null; }
939
+ else { cur = { q: clip(m[1]), context: "", options: [] }; questions.push(cur); }
940
+ continue;
941
+ }
942
+ if ((m = l.match(/^##\s+(.+)$/))) { sec = /suggest|idea|follow|option/i.test(m[1]) ? "s" : "q"; cur = null; continue; }
943
+ if (/^#\s/.test(l)) continue;
944
+ const b = l.match(/^(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.+)$/);
945
+ if (sec === "s") { if (b) suggestions.push(clip(b[1])); continue; }
946
+ if (b) { if (cur) cur.options.push(clip(b[1])); else questions.push({ q: clip(b[1]), context: "", options: [] }); continue; }
947
+ if (cur) cur.context = clip((cur.context ? cur.context + " " : "") + l);
948
+ }
949
+ return { questions: questions.filter((x) => x.q).slice(0, 20).map((x) => ({ ...x, options: x.options.slice(0, 6) })), suggestions: suggestions.filter(Boolean).slice(0, 10) };
950
+ }
951
+ // The OPEN questions (not yet in ANSWERS.md) and the agent's ideas, each marked
952
+ // if it's already on the task list for this repo.
953
+ function agentQuestions(path, repo) {
954
+ const p = parseQuestions(readSymbiot(path, "QUESTIONS.md"));
955
+ const done = new Set([...readSymbiot(path, "ANSWERS.md").matchAll(/^###\s+(.+)$/gm)].map((m) => qKey(m[1])));
956
+ const open = p.questions.filter((x) => !done.has(qKey(x.q)));
957
+ const tasks = p.suggestions.length ? loadTasks() : [];
958
+ return {
959
+ questions: open.slice(0, QUESTIONS_MAX), answered: p.questions.length - open.length,
960
+ suggestions: p.suggestions.map((text) => ({ text, added: tasks.some((t) => t.repo === repo && qKey(t.text) === qKey(text)) })),
961
+ };
962
+ }
963
+ // Save answers to .symbiot/ANSWERS.md; opts.rerun hands the repo back to the
964
+ // agent (same saved command) so it carries on with them.
965
+ function answerQuestions(path, answers, opts = {}) {
966
+ path = String(path || "");
967
+ if (!path || !HANDOFFS.some((e) => e.path === path)) return { error: "No agent has run in that folder." };
968
+ const open = new Map(agentQuestions(path, "").questions.map((x) => [qKey(x.q), x.q]));
969
+ const rows = (Array.isArray(answers) ? answers : [])
970
+ .map((x) => ({ q: open.get(qKey(x && x.q)), a: String((x && x.a) || "").replace(/^\s*#+/gm, "").trim().slice(0, 2000) }))
971
+ .filter((x) => x.q && x.a);
972
+ if (!rows.length) return { error: "Pick or type at least one answer." };
973
+ const prev = readSymbiot(path, "ANSWERS.md") || "# Answers from the user\nAnswers to the questions in QUESTIONS.md, newest last. Follow them; ask again in QUESTIONS.md if one is unclear.\n";
974
+ const day = new Date().toISOString().slice(0, 10);
975
+ try {
976
+ mkdirSync(join(path, ".symbiot"), { recursive: true });
977
+ writeFileSync(join(path, ".symbiot", "ANSWERS.md"), prev.replace(/\s*$/, "\n") + rows.map((x) => `\n### ${x.q}\n${x.a}\n_answered ${day}_\n`).join(""));
978
+ } catch (e) { return { error: "Couldn't write ANSWERS.md: " + ((e && e.message) || e) }; }
979
+ const out = { ok: true, saved: rows.length };
980
+ if (opts.rerun) { const e = runHandoff(path); if (e) out.rerun = e.id; else out.note = "Answers saved. Set an agent command in Settings to have the agent pick them up automatically."; }
981
+ return out;
982
+ }
983
+ // The Agents tab: every tracked job, its log tail, what it changed, and — on the
984
+ // newest job per folder — the questions/ideas it left.
985
+ function agentsList() {
986
+ const seen = new Set();
987
+ return HANDOFFS.map((e) => {
988
+ let tail = ""; try { tail = readFileSync(e.log, "utf8").slice(-1200); } catch {}
989
+ const first = !seen.has(e.path); seen.add(e.path);
990
+ return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null };
991
+ });
992
+ }
890
993
  // ---- local model one-command setup (Ollama), OS-aware ---------------------
891
994
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
892
995
  function hasOllama() { return !!sh("ollama --version").trim(); } // cross-platform (not POSIX command -v)
@@ -1469,6 +1572,33 @@ function discoveredRepos() {
1469
1572
  if (LAST_MAP && LAST_MAP.nodes) return LAST_MAP.nodes.filter((n) => n.type === "repo" && n.meta && n.meta.path).map((n) => ({ path: n.meta.path, name: n.label, recency: 0 }));
1470
1573
  return findAllRepos();
1471
1574
  }
1575
+ // ---- email: opt-in, local, no API (see mail.mjs) ---------------------------
1576
+ // config.mail = { enabled, sources: [extra folders/.mbox files], addresses: [your
1577
+ // mail addresses, to pick your mail out of a whole-mailbox export] }.
1578
+ function mailCfg() {
1579
+ const m = loadConfig().mail || {};
1580
+ return { enabled: !!m.enabled, sources: Array.isArray(m.sources) ? m.sources : [], addresses: Array.isArray(m.addresses) ? m.addresses : [] };
1581
+ }
1582
+ function mailState() { return { ...mailCfg(), detected: detectMailSources() }; }
1583
+ function setMail(b = {}) {
1584
+ const m = mailCfg();
1585
+ if (typeof b.enabled === "boolean") m.enabled = b.enabled;
1586
+ if (b.add) {
1587
+ const p = expandRoot(b.add);
1588
+ if (!p || !existsSync(p)) return { ...mailState(), error: "not found: " + (p || "(empty)") };
1589
+ if (!m.sources.includes(p)) m.sources.push(p);
1590
+ }
1591
+ if (b.remove) m.sources = m.sources.filter((x) => x !== b.remove);
1592
+ if (typeof b.addresses === "string") m.addresses = b.addresses.split(/[\s,;]+/).map((s) => s.trim().toLowerCase()).filter((s) => s.includes("@"));
1593
+ const cfg = loadConfig(); cfg.mail = m; saveConfig(cfg);
1594
+ return mailState();
1595
+ }
1596
+ // Mail you sent in the window — [] unless it's switched on (preview ignores that).
1597
+ function sentMail(days, preview = false) {
1598
+ const m = mailCfg(); if (!m.enabled && !preview) return [];
1599
+ return mailActivity({ days, sources: m.sources, addresses: [...m.addresses, me().email].filter(Boolean) });
1600
+ }
1601
+ const renderMail = (list) => list.slice(0, 60).map((x) => `- ${x.date} · to ${x.to.join(", ") || "?"} · ${x.subject}`).join("\n");
1472
1602
  async function produce(cmd) {
1473
1603
  if (!resolveProvider()) return { error: "not-connected" };
1474
1604
  if (cmd === "todo") {
@@ -1488,26 +1618,30 @@ async function produce(cmd) {
1488
1618
  // Discover the Map's repo set, then keep only those with commits in the window.
1489
1619
  const all = discoveredRepos();
1490
1620
  const repos = all.filter((r) => sh(`git -C ${JSON.stringify(r.path)} log --since="${days} days ago" --oneline -1 2>/dev/null`).trim());
1491
- if (!repos.length) return { text: `No commits in the last ${days} days across your ${all.length} repos.\nAdd folders to scan in Settings, or check your git identity.`, sub: "no activity" };
1621
+ const mail = sentMail(days); // [] unless email is switched on
1622
+ if (!repos.length && !mail.length) return { text: `No commits in the last ${days} days across your ${all.length} repos.\nAdd folders to scan in Settings, or check your git identity.`, sub: "no activity" };
1492
1623
  let cs = commits(repos, `${days} days ago`, !has("all"));
1493
1624
  if (!cs.length) cs = commits(repos, `${days} days ago`, false); // fall back to all if none matched you
1494
1625
  const open = label === "week" ? openWork(repos) : [];
1495
- if (!cs.length) return { text: "Found repos, but no commits in the window.", sub: "no commits" };
1626
+ if (!cs.length && !mail.length) return { text: "Found repos, but no commits in the window.", sub: "no commits" };
1496
1627
 
1497
1628
  const system =
1498
- `You write a short, first-person work update from a person's git commits. ` +
1629
+ `You write a short, first-person work update from a person's git commits${mail.length ? " and the emails they sent" : ""}. ` +
1499
1630
  `Write as them ("I"), plainly and specifically, grouped by theme or project, most important first. ` +
1500
1631
  `Turn commit messages into outcomes a manager or teammate would understand — not a raw commit list. ` +
1632
+ (mail.length ? `Fold the emails into those themes (a decision, a hand-off, who they worked with); skip routine ones (receipts, scheduling, one-line replies). You only have their subjects and recipients — don't guess at what they said. ` : "") +
1501
1633
  `${label === "standup" ? "Keep it to 3-5 bullets: done, and what's next." : "A short paragraph or a few grouped bullets; end with a one-line 'In progress / next' if there are open items."} ` +
1502
- `No preamble, no sign-off, no invented work — only what the commits and open items show.`;
1634
+ `No preamble, no sign-off, no invented work — only what the commits${mail.length ? ", emails" : ""} and open items show.`;
1503
1635
  const prompt =
1504
1636
  `Person: ${who.name || "me"}. Window: ${label === "standup" ? "since yesterday" : `last ${days} days`}.\n\n` +
1505
- `Commits:\n${renderCommits(cs)}\n\n` +
1637
+ `Commits:\n${cs.length ? renderCommits(cs) : "(none)"}\n\n` +
1638
+ (mail.length ? `Emails I sent (date · to · subject):\n${renderMail(mail)}\n\n` : "") +
1506
1639
  (open.length ? `Open / in progress:\n${open.map((o) => `- ${o}`).join("\n")}\n\n` : "") +
1507
1640
  `Write the ${label === "standup" ? "standup" : "update"}.`;
1508
1641
 
1509
1642
  const text = await write(system, prompt);
1510
- return { text: text || "(couldn't reach the model)", sub: `${cs.length} commits across ${new Set(cs.map((x) => x.repo)).size} repos · ${label}`, footer: `symbiot ${VERSION} · ${all.length} repos (same as the Map) · ${repos.length} active · ${cs.length} commits in last ${days}d` };
1643
+ const mailNote = mail.length ? ` · ${mail.length} sent email${mail.length === 1 ? "" : "s"}` : "";
1644
+ return { text: text || "(couldn't reach the model)", sub: `${cs.length} commits across ${new Set(cs.map((x) => x.repo)).size} repos${mailNote} · ${label}`, footer: `symbiot ${VERSION} · ${all.length} repos (same as the Map) · ${repos.length} active · ${cs.length} commits in last ${days}d${mailNote}` };
1511
1645
  }
1512
1646
 
1513
1647
  async function cmdRun(cmd) {
@@ -1677,7 +1811,7 @@ async function cmdApp() {
1677
1811
  if (r && r.ok) {
1678
1812
  const p = await r.json().catch(() => ({}));
1679
1813
  if (p && p.version) {
1680
- const url = `http://127.0.0.1:${PORT}/?t=${TOKEN}`; const how = openApp(url);
1814
+ const url = `http://127.0.0.1:${PORT}/?t=${TOKEN}`; const how = process.env.SYMBIOT_NO_OPEN === "1" ? "" : openApp(url);
1681
1815
  console.log(`\n${c.g("●")} ${c.b("Symbiot")} is already running (v${p.version}) at ${c.b(url)}`);
1682
1816
  console.log(how ? c.d(` Opened the existing window (a ${how}).`) : c.d(" Open that URL in your browser."));
1683
1817
  console.log(c.d(" (Not starting a second copy. Set SYMBIOT_FORCE_NEW=1 to force one.)"));
@@ -1731,19 +1865,19 @@ async function cmdApp() {
1731
1865
  const e = track("ollama pull " + model, "ollama pull " + shSingle(model), homedir(), (code) => { if (code === 0) useOllamaModel(model); });
1732
1866
  return json(res, { started: true, model, id: e ? e.id : "" });
1733
1867
  }
1734
- if (u.pathname === "/api/agents") return json(res, HANDOFFS.map((e) => { let tail = ""; try { tail = readFileSync(e.log, "utf8").slice(-1200); } catch {} return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, changed: agentChanges(e.path, e.startedAt) }; }));
1868
+ if (u.pathname === "/api/agents") return json(res, agentsList());
1869
+ if (u.pathname === "/api/agents/answer" && req.method === "POST") { const b = await readBody(req); return json(res, answerQuestions(String(b.path || ""), b.answers, { rerun: !!b.rerun })); }
1870
+ if (u.pathname === "/api/mail") return json(res, mailState());
1871
+ if (u.pathname === "/api/mail/set" && req.method === "POST") { const b = await readBody(req); return json(res, setMail(b)); }
1872
+ if (u.pathname === "/api/mail/preview") { const items = sentMail(Number(u.searchParams.get("days")) || SINCE_WEEK, true); return json(res, { count: items.length, items: items.slice(0, 20) }); }
1735
1873
  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 produce(cmd)); }
1736
1874
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
1737
1875
  if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1") await checkLatest(); return json(res, { version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: semverGt(LATEST_VERSION, VERSION) }); }
1738
1876
  if (u.pathname === "/api/update" && req.method === "POST") {
1739
- // Install the EXACT newest version (not the `latest` tag, which npm's
1740
- // cache/propagation can resolve stale — that caused an update loop where
1741
- // the install kept re-fetching the same old version), then relaunch this
1742
- // same app (same port+token => same URL) and exit. --prefer-online skips
1743
- // a stale cached packument. The page's heartbeat reconnects and reloads.
1744
- const target = LATEST_VERSION && semverGt(LATEST_VERSION, VERSION) ? LATEST_VERSION : "latest";
1745
- const pkg = "symbiot@" + target;
1746
- const inst = (process.platform === "win32" ? "npm i -g " : "npm install -g ") + pkg + " --prefer-online";
1877
+ // Install the exact newest version (see updateCmd), then relaunch this
1878
+ // same app (same port+token => same URL) and exit. The page's heartbeat
1879
+ // reconnects and reloads.
1880
+ const { target, cmd: inst } = updateCmd(LATEST_VERSION, VERSION);
1747
1881
  const e = track("symbiot update", inst, homedir(), (code) => {
1748
1882
  if (code !== 0) return;
1749
1883
  try { const ch = spawn(process.execPath, process.argv.slice(1), { detached: true, stdio: "ignore", env: process.env }); ch.unref(); } catch {}
@@ -1774,6 +1908,24 @@ async function cmdApp() {
1774
1908
  checkLatest(); setInterval(checkLatest, 2 * 60 * 1000).unref(); // background update check (every 2 min)
1775
1909
  }
1776
1910
 
1911
+ // `symbiot mail [--on|--off] [--add <path>]`: what mail it can read, and a preview.
1912
+ function cmdMail() {
1913
+ if (has("on") || has("off")) setMail({ enabled: has("on") });
1914
+ const add = flag("add", null);
1915
+ if (add) { const r = setMail({ add }); if (r.error) console.log(c.y(r.error)); }
1916
+ const m = mailState();
1917
+ console.log(`\n${c.g("●")} ${c.b("Symbiot mail")} ${c.d("(experimental) · " + (m.enabled ? "on — what you sent feeds week and standup" : "off"))}\n`);
1918
+ const srcs = [...m.detected.map((d) => [d.path, d.kind]), ...m.sources.map((p) => [p, "added"])];
1919
+ if (!srcs.length) console.log(c.y("No mail found on this computer.") + c.d(" Use a desktop mail app (Thunderbird, Apple Mail, Evolution…), or add an export: symbiot mail --add ~/Takeout/Mail/All.mbox"));
1920
+ for (const [p, k] of srcs) console.log(` ${c.g("·")} ${p} ${c.d(k)}`);
1921
+ if (srcs.length) {
1922
+ const items = sentMail(SINCE_WEEK, true);
1923
+ console.log("\n" + c.b(`${items.length} sent in the last ${SINCE_WEEK} days`) + c.d(" (headers only)"));
1924
+ for (const x of items.slice(0, 12)) console.log(` ${c.d(x.date)} ${x.subject} ${c.d("→ " + (x.to.join(", ") || "?"))}`);
1925
+ }
1926
+ if (!m.enabled) console.log("\n" + c.d("Switch it on with symbiot mail --on (or in the app's Settings)."));
1927
+ }
1928
+
1777
1929
  const HELP = `${c.b("symbiot")} — your week, written from your real work.
1778
1930
 
1779
1931
  ${c.b("Usage")}
@@ -1781,15 +1933,19 @@ ${c.b("Usage")}
1781
1933
  symbiot standup yesterday + today, for standup
1782
1934
  symbiot todo what's still on your plate
1783
1935
  symbiot app open the visual app in your browser
1784
- symbiot drift what's out of sync / at risk across repos
1936
+ symbiot drift [--fetch] what's out of sync / at risk across repos
1785
1937
  symbiot push [--open] write tasks into each repo (and run your agent)
1786
- symbiot models recommend AI models for your hardware
1787
- symbiot setup-local [--model X] install/run a free local model (Ollama)
1788
1938
  symbiot login connect it to an AI (once)
1789
1939
  symbiot whoami show how it's connected
1790
1940
  symbiot logout forget saved credentials
1791
1941
  symbiot help
1792
1942
 
1943
+ ${c.b("Experimental")}
1944
+ symbiot drift --ci also check GitHub Actions (needs gh)
1945
+ symbiot mail [--on|--off] use the mail you sent in write-ups (local, no API)
1946
+ symbiot models recommend AI models for your hardware
1947
+ symbiot setup-local [--model X] install/run a free local model (Ollama)
1948
+
1793
1949
  ${c.b("Options")}
1794
1950
  --dir <path> where your repos are (default: ${homedir()})
1795
1951
  --since <days> window for 'week' (default 7)
@@ -1802,9 +1958,10 @@ ${c.b("Setup")} pick any AI to write with:
1802
1958
  ${c.d("Env keys also work: ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY.")}
1803
1959
  ${c.d("Override the model per run with SYMBIOT_MODEL.")}
1804
1960
 
1805
- ${c.d("Reads only your local git. No accounts, no OAuth, no data leaves except the")}
1806
- ${c.d("commit summaries sent to the AI to write your update (nothing leaves at all")}
1807
- ${c.d("with a local Ollama model).")}`;
1961
+ ${c.d("Reads only your local git (and, if you switch it on, the headers of mail you")}
1962
+ ${c.d("sent). No accounts, no OAuth, no data leaves except the commit and email")}
1963
+ ${c.d("subjects sent to the AI to write your update (nothing leaves at all with a")}
1964
+ ${c.d("local Ollama model).")}`;
1808
1965
 
1809
1966
  // ---- main -----------------------------------------------------------------
1810
1967
  async function main() {
@@ -1817,6 +1974,7 @@ async function main() {
1817
1974
  if (cmd === "setup-local" || cmd === "setup-ollama") return cmdSetupLocal();
1818
1975
  if (cmd === "drift") return cmdDrift();
1819
1976
  if (cmd === "push") return cmdPush();
1977
+ if (cmd === "mail" || cmd === "email") return cmdMail();
1820
1978
  if (cmd === "week") return cmdRun("week");
1821
1979
  if (cmd === "standup") return cmdRun("standup");
1822
1980
  if (cmd === "todo") return cmdRun("todo");
@@ -1832,4 +1990,4 @@ const isMain = (() => {
1832
1990
  })();
1833
1991
  if (isMain) main();
1834
1992
 
1835
- export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, taskType, EMBEDDED_UI, orcaHandoffCmd, migrateOrcaCmd, fillHandoff, handoffCmd, setHandoffCmd, ORCA_CLAUDE_CMD, HANDOFF_PROMPT, shipChanges, syncTasks, pendingReview, approveRepo, sendBack, pushTasks, semverGt };
1993
+ 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, syncTasks, pendingReview, approveRepo, sendBack, pushTasks, semverGt, updateCmd, parseQuestions, agentQuestions };
package/mail.mjs ADDED
@@ -0,0 +1,155 @@
1
+ // symbiot — your email, without an API.
2
+ //
3
+ // Reads the mail a desktop client already keeps on this computer (Thunderbird,
4
+ // Apple Mail, Evolution, KMail, mutt/neomutt — any Maildir or mbox) or an
5
+ // exported .mbox (e.g. Google Takeout). No OAuth, no app registration, no
6
+ // password, no server: it works the same for anyone with mail on disk, which is
7
+ // how everyone else can link theirs too. Only headers are read (Date, From, To,
8
+ // Subject, Message-ID), never a message body — and only mail YOU sent, so a
9
+ // write-up gets "what I did over email", not your inbox.
10
+ import { homedir } from "node:os";
11
+ import { join, basename, dirname } from "node:path";
12
+ import { readdirSync, statSync, openSync, readSync, closeSync, fstatSync } from "node:fs";
13
+
14
+ const TAIL_BYTES = 24 * 1024 * 1024; // an mbox appends: the newest mail is at the end
15
+ const HEAD_BYTES = 64 * 1024; // enough for one message's headers
16
+ const MAX_FILES = 600; // message files read per folder
17
+ const MAX_ITEMS = 80;
18
+ const WALK_MS = 8000; // one folder walk
19
+ const READ_MS = 15000; // everything, all sources
20
+ const SKIP_DIRS = new Set(["node_modules", ".git", "Attachments", "attachments", "cache2", "startupCache", "crashes", "minidumps", "storage", "datareporting", "extensions"]);
21
+ const NOT_MAIL = /\.(msf|dat|sqlite|sqlite-wal|sqlite-shm|json|plist|db|summary|cmeta|ibex|index|lock|log|txt|js|html|css|png|jpg|gif)$/i;
22
+ const SENTNAME = /^\.?sent\b/i; // Sent, Sent Mail, Sent Items, Sent Messages(.mbox), .Sent
23
+
24
+ // Bounded directory walk in plain JS (no `find`, so it also works on Windows).
25
+ function walk(root, { depth = 6, limit = 200, dirs = false, match, deadline = Date.now() + WALK_MS }) {
26
+ const out = [];
27
+ const go = (dir, d) => {
28
+ if (out.length >= limit || Date.now() > deadline) return;
29
+ let ents; try { ents = readdirSync(dir, { withFileTypes: true }); } catch { return; }
30
+ for (const e of ents) {
31
+ if (out.length >= limit || Date.now() > deadline) return;
32
+ const p = join(dir, e.name);
33
+ if (e.isDirectory()) {
34
+ if (dirs && match(e.name, p)) { out.push(p); continue; }
35
+ if (d < depth && !SKIP_DIRS.has(e.name)) go(p, d + 1);
36
+ } else if (!dirs && e.isFile() && match(e.name, p)) out.push(p);
37
+ }
38
+ };
39
+ go(root, 0);
40
+ return out;
41
+ }
42
+
43
+ // Where desktop mail clients keep sent mail, per OS. Returns [{ path, kind }].
44
+ export function detectMailSources() {
45
+ const H = homedir(), out = [];
46
+ const add = (kind, paths) => { for (const p of paths) if (!out.some((x) => x.path === p)) out.push({ path: p, kind }); };
47
+ const sentFile = (n) => SENTNAME.test(n) && !NOT_MAIL.test(n);
48
+ const tb = process.platform === "darwin" ? [join(H, "Library/Thunderbird/Profiles")]
49
+ : process.platform === "win32" ? [join(process.env.APPDATA || join(H, "AppData/Roaming"), "Thunderbird/Profiles")]
50
+ : [join(H, ".thunderbird"), join(H, "snap/thunderbird/common/.thunderbird"), join(H, ".var/app/org.mozilla.Thunderbird/.thunderbird")];
51
+ for (const r of tb) add("Thunderbird", walk(r, { depth: 6, limit: 12, match: sentFile })); // mbox files: Mail/…/Sent, ImapMail/…/Sent Mail
52
+ if (process.platform === "darwin") add("Apple Mail", walk(join(H, "Library/Mail"), { depth: 4, limit: 12, dirs: true, match: (n) => SENTNAME.test(n) && /\.mbox$/i.test(n) }));
53
+ if (process.platform === "linux") {
54
+ for (const r of [join(H, ".local/share/evolution/mail"), join(H, ".cache/evolution/mail")]) add("Evolution", walk(r, { depth: 5, limit: 8, dirs: true, match: (n) => SENTNAME.test(n) }));
55
+ add("KMail", walk(join(H, ".local/share/local-mail"), { depth: 3, limit: 4, dirs: true, match: (n) => SENTNAME.test(n) }));
56
+ }
57
+ for (const r of [join(H, "Maildir"), join(H, "mail"), join(H, "Mail"), join(H, ".mail")]) {
58
+ add("Maildir", walk(r, { depth: 3, limit: 6, dirs: true, match: (n) => SENTNAME.test(n) }));
59
+ add("mbox", walk(r, { depth: 3, limit: 6, match: sentFile }));
60
+ }
61
+ return out.slice(0, 24);
62
+ }
63
+
64
+ function readChunk(file, bytes, fromEnd) {
65
+ let fd;
66
+ try {
67
+ fd = openSync(file, "r");
68
+ const size = fstatSync(fd).size, len = Math.min(bytes, size), buf = Buffer.alloc(len);
69
+ readSync(fd, buf, 0, len, fromEnd ? size - len : 0);
70
+ return { text: buf.toString("utf8"), partial: fromEnd && size > len };
71
+ } catch { return { text: "", partial: false }; }
72
+ finally { if (fd !== undefined) try { closeSync(fd); } catch {} }
73
+ }
74
+ // RFC 5322 header block -> { lowercased name: first value }, folded lines joined.
75
+ export function parseHeaders(raw) {
76
+ const s = String(raw || ""), end = s.search(/\r?\n\r?\n/);
77
+ const head = (end >= 0 ? s.slice(0, end) : s).replace(/\r?\n[ \t]+/g, " ");
78
+ const h = {};
79
+ for (const line of head.split(/\r?\n/)) {
80
+ const i = line.indexOf(":"); if (i <= 0) continue;
81
+ const k = line.slice(0, i).trim().toLowerCase(); if (!(k in h)) h[k] = line.slice(i + 1).trim();
82
+ }
83
+ return h;
84
+ }
85
+ // RFC 2047 encoded words: =?UTF-8?B?…?= / =?iso-8859-1?Q?…?=
86
+ export function decodeWords(s) {
87
+ return String(s || "").replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=(?:\s+(?==\?))?/g, (all, cs, enc, txt) => {
88
+ try {
89
+ const buf = /b/i.test(enc) ? Buffer.from(txt, "base64")
90
+ : Buffer.from(txt.replace(/_/g, " ").replace(/=([0-9A-Fa-f]{2})/g, (x, hx) => String.fromCharCode(parseInt(hx, 16))), "latin1");
91
+ return new TextDecoder(/^(utf-?8|us-ascii)$/i.test(cs) ? "utf-8" : cs.replace(/\*.*$/, "").toLowerCase()).decode(buf);
92
+ } catch { return all; }
93
+ });
94
+ }
95
+ // "Ann <a@x.com>, b@y.com" -> [{ name, addr }]
96
+ export function people(v) {
97
+ const out = [];
98
+ for (const part of decodeWords(v).split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)) {
99
+ const m = part.match(/^\s*"?([^"<]*?)"?\s*<([^>]+)>/);
100
+ const addr = (m ? m[2] : part).trim().toLowerCase();
101
+ if (addr.includes("@")) out.push({ name: m ? m[1].trim() : "", addr });
102
+ }
103
+ return out;
104
+ }
105
+ function toItem(h, fallbackTs) {
106
+ if (parseInt(h["x-mozilla-status"] || "0", 16) & 0x0008) return null; // Thunderbird: deleted, not yet compacted away
107
+ const ts = Date.parse(String(h.date || "").replace(/\s*\([^)]*\)\s*$/, "")) || fallbackTs || 0;
108
+ return {
109
+ ts, id: String(h["message-id"] || "").trim(), labels: String(h["x-gmail-labels"] || ""),
110
+ from: people(h.from || "")[0] || null, to: people([h.to, h.cc].filter(Boolean).join(", ")),
111
+ subject: decodeWords(h.subject || "").replace(/\s+/g, " ").trim().slice(0, 200) || "(no subject)",
112
+ };
113
+ }
114
+ // One mbox file: read its tail, split on the "From " separator lines.
115
+ function readMbox(file) {
116
+ const { text, partial } = readChunk(file, TAIL_BYTES, true);
117
+ const parts = text.split(/\r?\n(?=From )/);
118
+ if (partial) parts.shift(); // starts mid-message
119
+ return parts.map((p) => toItem(parseHeaders(p.replace(/^From [^\n]*\n/, "")), 0)).filter(Boolean);
120
+ }
121
+ // A folder of one-file-per-message mail: Maildir cur/new, .eml, Apple Mail .emlx.
122
+ function readMailDir(dir, since, deadline) {
123
+ const msg = (n, p) => !n.startsWith(".") && !NOT_MAIL.test(n) && (/\.(eml|emlx)$/i.test(n) || /^(cur|new)$/.test(basename(dirname(p))) || /[\\/](cur|new)[\\/]/.test(p.slice(dir.length)));
124
+ const recent = (p) => { try { return statSync(p).mtimeMs >= since - 86400000; } catch { return false; } };
125
+ const files = walk(dir, { depth: 8, limit: MAX_FILES, deadline: Math.min(deadline, Date.now() + WALK_MS), match: (n, p) => msg(n, p) && recent(p) });
126
+ return files.map((f) => {
127
+ const t = readChunk(f, HEAD_BYTES, false).text.replace(/^\d+[ \t]*\r?\n/, ""); // .emlx starts with a byte count
128
+ let mt = 0; try { mt = statSync(f).mtimeMs; } catch {}
129
+ return toItem(parseHeaders(t), mt);
130
+ }).filter(Boolean);
131
+ }
132
+ const sentLabel = (l) => /(^|,)\s*"?sent"?\s*(,|$)/i.test(l); // Google Takeout's X-Gmail-Labels
133
+
134
+ // Mail you sent in the last `days`: from the detected sources plus any you added.
135
+ // Mail in a Sent folder is yours; elsewhere (e.g. a whole-mailbox export) it
136
+ // counts only with Gmail's Sent label or a From matching one of `addresses`.
137
+ export function mailActivity({ days = 7, sources = [], addresses = [], auto = true } = {}) {
138
+ const now = Date.now(), since = now - days * 86400000, deadline = now + READ_MS;
139
+ const me = new Set(addresses.map((a) => String(a).trim().toLowerCase()).filter(Boolean));
140
+ const paths = [...new Set([...(auto ? detectMailSources().map((s) => s.path) : []), ...sources])];
141
+ const seen = new Set(), items = [];
142
+ for (const p of paths) {
143
+ if (Date.now() > deadline) break;
144
+ let st; try { st = statSync(p); } catch { continue; }
145
+ if (!st.isDirectory() && st.mtimeMs < since) continue; // nothing appended in the window
146
+ const sentSrc = SENTNAME.test(basename(p));
147
+ for (const m of st.isDirectory() ? readMailDir(p, since, deadline) : readMbox(p)) {
148
+ if (m.ts < since || m.ts > now + 86400000) continue;
149
+ if (!(sentSrc || sentLabel(m.labels) || (m.from && me.has(m.from.addr)))) continue;
150
+ const k = m.id || m.ts + "|" + m.subject; if (seen.has(k)) continue; seen.add(k);
151
+ items.push({ ts: m.ts, date: new Date(m.ts).toISOString().slice(0, 10), to: m.to.slice(0, 3).map((x) => x.name || x.addr), subject: m.subject, source: p });
152
+ }
153
+ }
154
+ return items.sort((a, b) => b.ts - a.ts).slice(0, MAX_ITEMS);
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.32.0",
3
+ "version": "0.34.0",
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": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "index.mjs",
11
11
  "ui.mjs",
12
+ "mail.mjs",
12
13
  "README.md",
13
14
  "LICENSE"
14
15
  ],
@@ -43,6 +44,6 @@
43
44
  "@anthropic-ai/sdk": "^0.128.0"
44
45
  },
45
46
  "scripts": {
46
- "test": "node test/load.mjs && node test/fixtures.mjs && node test/smoke.mjs && node test/install.mjs"
47
+ "test": "node test/load.mjs && node test/fixtures.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs"
47
48
  }
48
49
  }
package/ui.mjs CHANGED
@@ -105,6 +105,17 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
105
105
  .commits{margin-top:6px;font-size:12px;color:var(--text);font-family:ui-monospace,Menlo,Consolas,monospace}
106
106
  .commits code{color:var(--amber);margin-right:6px}
107
107
  @keyframes pulse{0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(242,165,65,.5)}50%{opacity:.4;box-shadow:0 0 0 5px rgba(242,165,65,0)}}
108
+ .aq{margin-top:10px;border:1px solid var(--amber);border-radius:9px;padding:10px 12px;background:var(--ink)}
109
+ .aq h4{margin:4px 0 6px;color:var(--amber);font-size:11px;letter-spacing:.12em;text-transform:uppercase;font-weight:700}
110
+ .aq .q{padding:8px 0;border-bottom:1px solid var(--line)}
111
+ .aq .qt{color:var(--bone);font-weight:600;font-size:13.5px}
112
+ .aq .qc{color:var(--faint);font-size:12.5px;margin-top:3px;line-height:1.5}
113
+ .aq label.opt{display:flex;gap:8px;align-items:flex-start;margin:6px 0 0;font-size:13px;color:var(--text);cursor:pointer}
114
+ .aq .opt input{width:auto;margin-top:3px;flex:none}
115
+ .aq .qother{margin-top:7px;padding:7px 10px;font-size:13px}
116
+ .aq .row{margin:10px 0 2px}
117
+ label.check{display:flex;gap:8px;align-items:center;font-size:13px;color:var(--text);margin:6px 0}
118
+ label.check input{width:auto}
108
119
  .alogout{white-space:pre-wrap;background:var(--ink);border:1px solid var(--line);border-radius:8px;padding:10px;margin-top:10px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;line-height:1.5;color:var(--text);max-height:260px;overflow:auto}
109
120
  .bar{height:3px;background:var(--green-dim);border-radius:2px;overflow:hidden;margin-top:8px}
110
121
  .bar > i{display:block;height:100%;width:35%;background:var(--green);border-radius:2px;animation:slide 1.3s ease-in-out infinite}
@@ -165,13 +176,14 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
165
176
  <div id="tasklist"></div>
166
177
  </section>
167
178
  <section id="panel-agents" class="hidden">
168
- <div class="row"><span class="muted">Agents Symbiot has handed work to — live status and output.</span><button class="ghost" id="agentsrefresh" style="margin-left:auto">Refresh</button></div>
179
+ <div class="row"><span class="muted">Agents Symbiot has handed work to — live status and output. Questions, options and ideas an agent leaves for you show up on its block, whichever model it runs.</span><button class="ghost" id="agentsrefresh" style="margin-left:auto">Refresh</button></div>
180
+ <div id="agentsmsg"></div>
169
181
  <div id="agentslist"></div>
170
182
  </section>
171
183
  <section id="panel-drift" class="hidden">
172
184
  <div class="row"><span class="muted">What's out of sync, stuck or at risk across your repos — local git facts.</span>
173
185
  <label class="muted" style="margin-left:auto"><input type="checkbox" id="driftfetch"> fetch latest</label>
174
- <label class="muted"><input type="checkbox" id="driftci"> check CI (needs gh)</label>
186
+ <label class="muted"><input type="checkbox" id="driftci"> check CI (needs gh, experimental)</label>
175
187
  <button class="ghost" id="driftrun">Rescan</button></div>
176
188
  <div id="driftout"></div>
177
189
  </section>
@@ -204,6 +216,16 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
204
216
  <div id="agentpresets" style="margin-top:8px"></div>
205
217
  </div>
206
218
  <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
219
+ <label>Email &mdash; add what you sent to Week and Standup <span class="muted">(experimental)</span></label>
220
+ <label class="check"><input type="checkbox" id="mailon"> Use my sent email (subjects &amp; recipients only)</label>
221
+ <div id="mailsources"></div>
222
+ <div class="row" style="margin-top:6px"><input id="newmail" placeholder="add a mail folder or .mbox file (e.g. a Google Takeout export)" style="flex:1"><button class="ghost" id="addmail">Add</button><button class="ghost" id="mailpreview">Preview</button></div>
223
+ <input id="mailaddrs" type="text" placeholder="your email addresses, comma-separated (only needed for a whole-mailbox export)">
224
+ <div class="note muted" id="mailnote">No API, no OAuth, no password: Symbiot reads the mail your desktop mail app (Thunderbird, Apple Mail, Evolution, mutt&hellip;) already keeps on this computer, or an exported .mbox &mdash; so anyone can link theirs. Headers only, never a message body; off until you tick it. Experimental: tested with mbox and Maildir, not yet on real Apple Mail, Evolution or KMail stores.</div>
225
+ <div id="mailout"></div>
226
+ </div>
227
+ <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
228
+ <label>Local models <span class="muted">(experimental)</span></label>
207
229
  <button class="ghost" id="recbtn">Recommend models for my machine</button>
208
230
  <button class="ghost" id="setuplocal" style="margin-left:8px">Set up a free local model</button>
209
231
  <div id="setupout" class="note muted" style="margin-top:10px"></div>
@@ -380,10 +402,47 @@ var h="<div class='tgroup'>Archived <span class='tcount'>"+list.length+"</span><
380
402
  var agentsTimer=null;
381
403
  function stopAgentsPoll(){if(agentsTimer){clearTimeout(agentsTimer);agentsTimer=null;}}
382
404
  function fmtE(ms){var s=Math.floor(ms/1000);if(s<60)return s+'s';var m=Math.floor(s/60);return m+'m '+(s%60)+'s';}
405
+ // Questions, options and ideas an agent left in .symbiot/QUESTIONS.md (any
406
+ // agent: it's a file). Answers go to .symbiot/ANSWERS.md; "Send & continue"
407
+ // re-runs the agent so it picks them up. Text stays out of attributes (esc()
408
+ // doesn't escape quotes) — rows carry indexes into AGENTLIST instead.
409
+ var AGENTLIST=[],QDRAFT={};
410
+ function agentById(id){for(var i=0;i<AGENTLIST.length;i++)if(AGENTLIST[i].id===id)return AGENTLIST[i];return null;}
411
+ function nQs(a){return (a.ask&&a.ask.questions)?a.ask.questions.length:0;}
412
+ function askHtml(a){var k=a.ask;if(!k)return '';var qs=k.questions||[],ss=k.suggestions||[];if(!qs.length&&!ss.length)return '';
413
+ var h="<div class='aq' data-id='"+esc(a.id)+"'>";
414
+ if(qs.length){h+="<h4>&#10067; "+qs.length+" question"+(qs.length>1?"s":"")+" for you</h4>";
415
+ qs.forEach(function(q,i){h+="<div class='q' data-i='"+i+"'><div class='qt'>"+esc(q.q)+"</div>"+(q.context?"<div class='qc'>"+esc(q.context)+"</div>":"");
416
+ (q.options||[]).forEach(function(o,j){h+="<label class='opt'><input type='radio' name='q_"+esc(a.id)+"_"+i+"' value='"+j+"'><span>"+esc(o)+"</span></label>";});
417
+ h+="<input class='qother' placeholder='"+((q.options&&q.options.length)?"or answer in your own words":"your answer")+"'></div>";});
418
+ h+="<div class='row'><button class='act qsend' title='save the answers and hand the repo back to your agent'>Send answers &amp; continue</button><button class='ghost qsave' title='save the answers for the next run'>Save only</button></div>";}
419
+ if(ss.length){h+="<h4>&#128161; Ideas from the agent</h4>";ss.forEach(function(s,i){h+="<div class='idea'><span style='flex:1'>"+esc(s.text)+"</span>"+(s.added?"<span class='tag'>in Tasks</span>":"<button class='ghost qidea' data-i='"+i+"' style='padding:3px 9px;font-size:12px'>+ task</button>")+"</div>";});}
420
+ return h+"</div>";}
421
+ function qKeyOf(qe){var box=qe.closest('.aq');var a=box&&agentById(box.getAttribute('data-id'));var q=a&&a.ask.questions[+qe.getAttribute('data-i')];return q?a.path+'|'+q.q:'';}
422
+ function saveDrafts(el){el.querySelectorAll('.aq .q').forEach(function(qe){var k=qKeyOf(qe);if(!k)return;var pick=qe.querySelector('input[type=radio]:checked');QDRAFT[k]={o:pick?pick.value:'',t:qe.querySelector('.qother').value};});}
423
+ function restoreDrafts(el){el.querySelectorAll('.aq .q').forEach(function(qe){var d=QDRAFT[qKeyOf(qe)];if(!d)return;qe.querySelectorAll('input[type=radio]').forEach(function(r){r.checked=r.value===d.o;});qe.querySelector('.qother').value=d.t||'';});}
424
+ function collectAnswers(box,a){var out=[];box.querySelectorAll('.q').forEach(function(qe){var q=a.ask.questions[+qe.getAttribute('data-i')];if(!q)return;
425
+ var other=(qe.querySelector('.qother').value||'').trim();var pick=qe.querySelector('input[type=radio]:checked');var ans=other||(pick?q.options[+pick.value]:'');if(ans)out.push({q:q.q,a:ans});});return out;}
426
+ function wireAsks(el){el.querySelectorAll('.aq').forEach(function(box){var a=agentById(box.getAttribute('data-id'));if(!a||!a.ask)return;
427
+ function send(rerun){var msg=document.getElementById('agentsmsg');var ans=collectAnswers(box,a);
428
+ if(!ans.length){msg.innerHTML="<div class='note err'>Pick an option or type an answer first.</div>";return;}
429
+ box.querySelectorAll('button').forEach(function(b){b.disabled=true;});
430
+ api('/api/agents/answer',{path:a.path,answers:ans,rerun:rerun}).then(function(r){
431
+ if(!r||r.error){box.querySelectorAll('button').forEach(function(b){b.disabled=false;});msg.innerHTML="<div class='note err'>"+esc((r&&r.error)||'failed')+"</div>";return;}
432
+ msg.innerHTML="<div class='note ok'>&#10003; Saved "+r.saved+" answer"+(r.saved>1?"s":"")+" for <b>"+esc(a.name)+"</b> in .symbiot/ANSWERS.md"+(r.rerun?" &middot; your agent is picking them up now.":".")+(r.note?"<div class='muted'>"+esc(r.note)+"</div>":"")+"</div>";loadAgents();});}
433
+ var s1=box.querySelector('.qsend'),s2=box.querySelector('.qsave');
434
+ if(s1)s1.addEventListener('click',function(){send(true);});if(s2)s2.addEventListener('click',function(){send(false);});
435
+ box.querySelectorAll('.qidea').forEach(function(btn){btn.addEventListener('click',function(){var s=a.ask.suggestions[+btn.getAttribute('data-i')];if(!s)return;btn.disabled=true;
436
+ api('/api/tasks/add',{text:s.text,repo:a.name}).then(function(){s.added=true;btn.outerHTML="<span class='tag'>in Tasks</span>";});});});});}
437
+ function answering(){var f=document.activeElement;return !!(f&&f.closest&&f.closest('.aq'));}
383
438
  function loadAgents(){api('/api/agents').then(function(list){var el=document.getElementById('agentslist');
384
- if(!list||!list.length){el.innerHTML="<div class='muted' style='margin-top:12px'>No agents yet. In <b>Tasks</b>, tick ideas and hit <b>Send to repos</b> (with an agent command set in Settings) &mdash; you'll watch it work here.</div>";stopAgentsPoll();return;}
439
+ if(!list||!list.length){AGENTLIST=[];el.innerHTML="<div class='muted' style='margin-top:12px'>No agents yet. In <b>Tasks</b>, tick ideas and hit <b>Send to repos</b> (with an agent command set in Settings) &mdash; you'll watch it work here.</div>";stopAgentsPoll();return;}
440
+ var anyRunning=list.some(function(a){return a.status==='running';});
441
+ if(answering()){stopAgentsPoll();if(anyRunning&&current==='agents')agentsTimer=setTimeout(loadAgents,2000);return;} // don't re-render under someone typing an answer
442
+ saveDrafts(el);AGENTLIST=list;
385
443
  el.innerHTML=list.map(function(a){var cls=a.status==='running'?'run':(a.status==='done'?'ok':'fail');
386
444
  var st=a.status==='running'?('working &middot; '+fmtE(a.elapsed)):(esc(a.status)+' &middot; '+fmtE(a.elapsed)+(a.exitCode!=null?' &middot; exit '+a.exitCode:''));
445
+ if(nQs(a))st+=" &middot; <span style='color:var(--amber)'>needs your answers</span>";
387
446
  var b="<div class='dh'><span class='adot "+cls+"'></span><span class='dn'>"+esc(a.name)+"</span><span class='dd'>"+st+"</span></div>";
388
447
  if(a.status==='running')b+="<div class='bar'><i></i></div>";
389
448
  var ch=a.changed;
@@ -396,10 +455,11 @@ if(ch&&(ch.dirty||ch.stat||(ch.commits&&ch.commits.length))){
396
455
  }else if(a.status==='done'){
397
456
  b+="<div class='changed muted'>No file changes detected (the agent may have only planned or asked).</div>";
398
457
  }
458
+ b+=askHtml(a);
399
459
  b+="<pre class='alogout'>"+esc((a.tail&&a.tail.trim())||'(waiting for output…)')+"</pre>";
400
460
  return "<div class='agent'>"+b+"</div>";}).join("");
401
461
  el.querySelectorAll('.alogout').forEach(function(p){p.scrollTop=p.scrollHeight;});
402
- var anyRunning=list.some(function(a){return a.status==='running';});
462
+ restoreDrafts(el);wireAsks(el);
403
463
  stopAgentsPoll();if(anyRunning&&current==='agents')agentsTimer=setTimeout(loadAgents,2000);});}
404
464
  function loadDrift(){var out=document.getElementById('driftout');out.innerHTML="<div class='muted' style='margin-top:12px'>Reading your repos&hellip;</div>";
405
465
  var ci=document.getElementById('driftci').checked?'1':'0';var ft=document.getElementById('driftfetch').checked?'1':'0';
@@ -430,7 +490,11 @@ function loadAgentCfg(){api('/api/agentcfg').then(function(d){document.getElemen
430
490
  var chips=[];(d.agents||[]).forEach(function(a){chips.push(a);});(d.editors||[]).forEach(function(e){chips.push(e);});
431
491
  var box=document.getElementById('agentpresets');
432
492
  if(!chips.length){box.innerHTML="<span class='muted' style='font-size:12px'>Nothing detected on PATH &mdash; type your own command above.</span>";return;}
433
- box.innerHTML="<span class='muted' style='font-size:12px'>Detected &mdash; click to use:</span><br>"+chips.map(function(c,i){return "<button class='ghost preset' data-i='"+i+"' style='padding:4px 10px;font-size:12px;margin:5px 5px 0 0'>"+esc(c.label)+"</button>";}).join("");
493
+ // Agents leave changes for review; an editor preset only opens the repo, so say so.
494
+ var na=(d.agents||[]).length;function chip(c,i){return "<button class='ghost preset' data-i='"+i+"' style='padding:4px 10px;font-size:12px;margin:5px 5px 0 0'>"+esc(c.label)+"</button>";}
495
+ var h="<span class='muted' style='font-size:12px'>Detected &mdash; click to use:</span><br>"+chips.slice(0,na).map(chip).join("");
496
+ if(chips.length>na)h+="<div class='muted' style='font-size:12px;margin-top:8px'>Editors &mdash; opens only, no review:</div>"+chips.slice(na).map(function(c,i){return chip(c,na+i);}).join("");
497
+ box.innerHTML=h;
434
498
  box.querySelectorAll('.preset').forEach(function(btn){btn.addEventListener('click',function(){document.getElementById('agentcmd').value=chips[+btn.getAttribute('data-i')].tmpl;saveAgent();});});});}
435
499
  function saveAgent(){api('/api/agentcmd',{cmd:document.getElementById('agentcmd').value});}
436
500
  function loadScanRoots(){api('/api/scanroots').then(function(d){var box=document.getElementById('scanroots');var roots=d.effective||[];
@@ -438,6 +502,23 @@ box.innerHTML=roots.map(function(r){var custom=(d.roots||[]).indexOf(r)>=0;retur
438
502
  box.querySelectorAll('.rmroot').forEach(function(btn){btn.addEventListener('click',function(){api('/api/scanroots/remove',{path:btn.closest('.task').getAttribute('data-p')}).then(function(){loadScanRoots();mapLoaded=false;driftLoaded=false;});});});});}
439
503
  function addRootUI(){var i=document.getElementById('newroot');var v=(i.value||'').trim();if(!v)return;var n=document.getElementById('scanrootnote');
440
504
  api('/api/scanroots/add',{path:v}).then(function(r){if(r.error){n.innerHTML="<span class='err'>"+esc(r.error)+"</span>";return;}i.value='';n.textContent='Added — the Map/Drift will rescan.';loadScanRoots();mapLoaded=false;driftLoaded=false;});}
505
+ // Email: opt-in, read from mail already on this computer (no API) — see mail.mjs.
506
+ function renderMail(d){if(!d)return;document.getElementById('mailon').checked=!!d.enabled;document.getElementById('mailaddrs').value=(d.addresses||[]).join(', ');
507
+ var rows=(d.detected||[]).map(function(s){return "<div class='task'><span class='t' style='font-family:ui-monospace,monospace;font-size:12px'>"+esc(s.path)+"</span><span class='rp'>"+esc(s.kind)+"</span></div>";});
508
+ (d.sources||[]).forEach(function(p,i){rows.push("<div class='task' data-i='"+i+"'><span class='t' style='font-family:ui-monospace,monospace;font-size:12px'>"+esc(p)+"</span><span class='rp'>added</span><button class='rm rmmail' title='remove'>&times;</button></div>");});
509
+ var box=document.getElementById('mailsources');
510
+ box.innerHTML=rows.length?rows.join(''):"<div class='muted' style='font-size:12px;margin-top:6px'>No mail app data found on this computer. Use a desktop mail app, or add an export (Google Takeout gives you an .mbox).</div>";
511
+ box.querySelectorAll('.rmmail').forEach(function(btn){btn.addEventListener('click',function(){var p=(d.sources||[])[+btn.closest('.task').getAttribute('data-i')];if(p)api('/api/mail/set',{remove:p}).then(renderMail);});});
512
+ if(d.error)document.getElementById('mailout').innerHTML="<div class='note err'>"+esc(d.error)+"</div>";}
513
+ function loadMail(){api('/api/mail').then(renderMail);}
514
+ function setMailOn(){api('/api/mail/set',{enabled:document.getElementById('mailon').checked}).then(renderMail);}
515
+ function saveMailAddrs(){api('/api/mail/set',{addresses:document.getElementById('mailaddrs').value}).then(renderMail);}
516
+ function addMailUI(){var i=document.getElementById('newmail');var v=(i.value||'').trim();if(!v)return;document.getElementById('mailout').innerHTML='';
517
+ api('/api/mail/set',{add:v}).then(function(d){if(d&&!d.error)i.value='';renderMail(d);});}
518
+ function previewMail(){var o=document.getElementById('mailout');o.innerHTML="<div class='muted' style='margin-top:8px'>Reading your sent mail&hellip;</div>";
519
+ api('/api/mail/preview?days=7').then(function(r){var items=(r&&r.items)||[];
520
+ if(!items.length){o.innerHTML="<div class='muted' style='margin-top:8px'>Nothing sent in the last 7 days found in these sources.</div>";return;}
521
+ o.innerHTML="<div class='out2'><b>"+r.count+"</b> sent in the last 7 days &mdash; what a write-up would see:<br>"+items.map(function(x){return esc(x.date)+" &nbsp;"+esc(x.subject)+" &nbsp;&rarr; "+esc((x.to||[]).join(', ')||'?');}).join("<br>")+"</div>";});}
441
522
  function setupLocalUI(){var o=document.getElementById('setupout');o.innerHTML="Setting up a local model&hellip;";
442
523
  api('/api/setup-local',{}).then(function(r){if(r.error==='not-installed'){var i=r.install||{};o.innerHTML="Ollama isn't installed. Run this once, then click again:<div class='out2'>"+esc(i.cmd||'')+"</div><div class='muted' style='font-size:11px'>or download: "+esc(i.alt||'https://ollama.com/download')+"</div>";return;}
443
524
  o.innerHTML="Pulling <b>"+esc(r.model)+"</b> &mdash; watch it in the <b>Agents</b> tab. Symbiot switches to it automatically when the download finishes.";setTimeout(function(){setTab('agents');},700);}).catch(function(e){o.innerHTML="<span class='err'>Setup failed: "+esc(String((e&&e.message)||e))+"</span>";});}
@@ -481,6 +562,11 @@ document.getElementById('agentsrefresh').addEventListener('click',loadAgents);
481
562
  document.getElementById('addtask').addEventListener('click',addTaskUI);
482
563
  document.getElementById('pushtasks').addEventListener('click',pushTasksUI);
483
564
  document.getElementById('agentcmd').addEventListener('change',saveAgent);
565
+ document.getElementById('mailon').addEventListener('change',setMailOn);
566
+ document.getElementById('mailaddrs').addEventListener('change',saveMailAddrs);
567
+ document.getElementById('addmail').addEventListener('click',addMailUI);
568
+ document.getElementById('newmail').addEventListener('keydown',function(e){if(e.key==='Enter')addMailUI();});
569
+ document.getElementById('mailpreview').addEventListener('click',previewMail);
484
570
  document.getElementById('newtask').addEventListener('keydown',function(e){if(e.key==='Enter')addTaskUI();});
485
571
  var SRV_STARTED=null,srvDown=false,updBusy=false;
486
572
  function ubar(){return document.getElementById('updatebar');}
@@ -514,5 +600,5 @@ function heartbeat(fresh){
514
600
  function doUpdate(){updBusy=true;try{localStorage.setItem('symbiot_update_tried',document.getElementById('ver').textContent.replace(/^v/,''));}catch(e){}var b=ubar();b.className='updatebar show';b.textContent='Updating & restarting… this page will reload itself when it is back.';api('/api/update',{});}
515
601
  setInterval(heartbeat,4000);heartbeat(true);
516
602
  window.addEventListener('focus',function(){heartbeat(true);}); // re-check for updates when you come back to the window
517
- initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();
603
+ initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();loadMail();
518
604
  </script></body></html>`;