symbiot 0.33.0 → 0.35.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.
package/README.md CHANGED
@@ -12,17 +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 mail add the mail you sent to your write-ups (local, no API)
18
- symbiot models recommend AI models for your hardware
19
- 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
20
17
  symbiot login connect it to an AI (once)
21
18
  symbiot whoami show how it's connected
22
19
  symbiot logout forget saved credentials
23
20
  symbiot help
24
21
  ```
25
22
 
23
+ A few newer extras (email, CI checks, local-model setup) are
24
+ [experimental](#experimental).
25
+
26
26
  ## Install
27
27
 
28
28
  ```bash
@@ -54,7 +54,7 @@ Tabs:
54
54
  **no AI key**; reviews and suggestions use your chosen model. While the scan runs
55
55
  the Map shows its progress.
56
56
  - **Drift** — the [`symbiot drift`](#whats-out-of-sync-symbiot-drift) report, with
57
- "fetch latest" and "check CI" toggles.
57
+ a "fetch latest" toggle (and an [experimental](#experimental) "check CI").
58
58
  - **Week / Standup / Todo** — the write-ups (these use your chosen AI).
59
59
  - **Tasks** — a checklist, grouped by kind (Fixes, Tests & CI, Docs, …). Filter by
60
60
  type or repo, then **Send to repos** to hand just those to your agent (see
@@ -71,9 +71,9 @@ Tabs:
71
71
  did** — files changed and commits made, read from git, whichever agent it was.
72
72
  When an agent leaves **questions, options or ideas** for you, they show up on its
73
73
  block (see [Questions from your agent](#questions-from-your-agent)).
74
- - **Settings** — your AI, the folders to scan, your agent command, your
75
- [email](#your-email-without-an-api-symbiot-mail), model recommendations and a
76
- one-click local model.
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.
77
77
 
78
78
  **Stays current by itself.** The app checks npm for a newer Symbiot every couple of
79
79
  minutes (and whenever you come back to the window); the version you're on is shown
@@ -82,7 +82,10 @@ version, relaunches the app on the same address, and the open window reloads its
82
82
  onto it. Restart `symbiot app` yourself and the open window reconnects the same way.
83
83
 
84
84
  The app listens on port **7391** so its address survives restarts; set
85
- `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.
86
89
  It works on **Linux, macOS and Windows**; with no Chromium-family browser to open
87
90
  the chrome-less window, it uses your default browser.
88
91
 
@@ -122,6 +125,12 @@ size of the uncommitted change and a **Show diff** button. For each repo:
122
125
 
123
126
  Tasks waiting for review aren't re-sent to the agent.
124
127
 
128
+ If a repo you sent tasks to has uncommitted changes but no ticked task (say the
129
+ agent made a fix and didn't tick anything), it still shows up here, with
130
+ **Approve changes without a task → PR**. That ships the changes the same way, on
131
+ a `symbiot/changes-<date>` branch, and leaves your tasks as they are. It also
132
+ lists your own uncommitted work in those repos, so check the diff first.
133
+
125
134
  ### Run your agent automatically
126
135
 
127
136
  Set an **agent command** in Settings (*Hand off to your agent*), and every
@@ -130,7 +139,7 @@ a template — `{dir}` is the repo path, `{prompt}` is the instruction to read
130
139
  `TASKS.md` — so it works with any agent or editor:
131
140
 
132
141
  ```
133
- claude -p "{prompt}" --permission-mode acceptEdits
142
+ claude -p "{prompt}" --permission-mode acceptEdits --allowedTools "Bash(npm test:*)" "Bash(node:*)"
134
143
  aider --message "{prompt}" --yes
135
144
  code {dir}
136
145
  ```
@@ -139,18 +148,36 @@ Settings shows **one-click presets** for what's installed on your machine. A
139
148
  preset only fills in the command box; the saved command is what runs.
140
149
 
141
150
  - **Agents** (all make changes, ready for your review): Claude Code, Codex
142
- (OpenAI), Aider, Cursor agent, Gemini CLI.
151
+ (OpenAI), Aider, Cursor agent, Gemini CLI. The Claude preset may also run
152
+ `npm test` and `node`, so it can check its own work; a command saved from the
153
+ older preset is upgraded automatically, and an edited one is left alone.
154
+ The others run as-is, with their own limits: Codex (`--full-auto`) can run
155
+ tests but has no network in its sandbox, so tests that download packages
156
+ fail; Gemini (`--yolo`) approves every shell command, tests included; Aider
157
+ doesn't run shell commands unattended, so it can't check its own work.
143
158
  - **Orca IDE** (any OS): opens the repo in Orca — either just the repo, to use
144
- Orca's own agent, or with Claude running in a new tab. It launches Orca if it's
145
- closed and waits for it to be ready first.
146
- - **Editors:** VS Code, Cursor, Windsurf, Zed, Sublime Text, IntelliJ IDEA, Neovim
147
- (on macOS, also found as `.app` bundles when the CLI isn't on your PATH).
159
+ Orca's own agent, or with Claude running in a new tab (allowed `npm test` and
160
+ `node` like the Claude preset, and upgraded the same way). It launches Orca if
161
+ it's closed and waits for it to be ready first.
162
+ - **Editors — opens only, no review:** VS Code, Cursor, Windsurf, Zed, Sublime
163
+ Text, IntelliJ IDEA, Neovim (on macOS, also found as `.app` bundles when the CLI
164
+ isn't on your PATH). They just open the repo. Nothing comes back for review unless
165
+ you run an agent there yourself, and Neovim needs a terminal to open in.
148
166
 
149
167
  Each run is logged to `.symbiot/agent.log` in the repo and shown live in the
150
168
  **Agents** tab. The command is saved as `agentCmd` in
151
169
  `~/.config/symbiot/config.json` — it's your command, Symbiot only fills in
152
170
  `{dir}` and `{prompt}`. A command saved from an older preset keeps working as-is.
153
171
 
172
+ One agent per repo: while a run is still going (tracked in
173
+ `.symbiot/agent.pid`, so the app and `symbiot push --open` both see it), sending
174
+ again doesn't start a second agent there, and doesn't rewrite the `TASKS.md` it's
175
+ working from either: the new tasks wait in `.symbiot/TASKS.next.md` and replace
176
+ `TASKS.md` when it finishes, keeping anything it ticked. Send again then to start
177
+ an agent on them. Orca and editor presets only open a tab and exit, so Symbiot
178
+ can't see the agent you run in them. Send to repos stays disabled until its
179
+ handoffs have started, so a double click can't send twice.
180
+
154
181
  ### Questions from your agent
155
182
 
156
183
  An agent working in your terminal or IDE stops to ask you things: which way to go,
@@ -184,7 +211,6 @@ committed.
184
211
  ```bash
185
212
  symbiot drift # local git facts only — fast, no network
186
213
  symbiot drift --fetch # fetch from origin first, so "behind" is current
187
- symbiot drift --ci # also check GitHub Actions state (needs gh)
188
214
  ```
189
215
 
190
216
  A **deterministic** report of what's out of sync, stuck, or at risk across your
@@ -195,10 +221,9 @@ not new work), **uncommitted work**, **behind upstream**, **multiple worktrees**
195
221
  branch** (the "merged but main didn't move" trap — split into *likely never
196
222
  landed*, when files the PR added are missing from main, and *probably re-done*,
197
223
  when they're all there), and — if you configure it — **production running code
198
- that isn't on your default branch**. With `--ci` it tells a real failing run apart
199
- from **CI that isn't running at all** (jobs never started — usually a billing or
200
- spending limit), quoting GitHub's reason when it can. Works on local-only repos
201
- too: without a remote it compares against your local default branch.
224
+ that isn't on your default branch**. Works on local-only repos too: without a
225
+ remote it compares against your local default branch. (CI checks with `--ci` are
226
+ [experimental](#ci-status-symbiot-drift---ci).)
202
227
 
203
228
  For the production check, add a per-repo deploy command to
204
229
  `~/.config/symbiot/deploys.json` (read only from your own config, never from a
@@ -206,31 +231,6 @@ repo), keyed by repo path or folder name, that prints the deployed commit sha:
206
231
  `{ "/path/to/repo": "ssh prod cat ~/app/.deployed-sha" }`. Also the **Drift** tab
207
232
  in the app.
208
233
 
209
- ## Which model? Ask your machine
210
-
211
- ```bash
212
- symbiot models
213
- ```
214
-
215
- Reads your RAM / CPU / GPU and recommends **local models by tier** (min / med / max,
216
- marking which fit your RAM) to run free & private via [Ollama](https://ollama.com),
217
- plus **paid** options (Claude / OpenAI / Gemini, cheap → top). Also available as
218
- a button in the app's Settings.
219
-
220
- ### A free local model in one command: `symbiot setup-local`
221
-
222
- ```bash
223
- symbiot setup-local # the best model for your RAM
224
- symbiot setup-local --model llama3.2:3b # or pick one
225
- ```
226
-
227
- Starts Ollama if it isn't running, downloads the model (with progress), and
228
- switches Symbiot over to it — free, private, nothing leaves your machine. If
229
- Ollama isn't installed yet it prints the one-line install for your OS (Homebrew on
230
- macOS, winget on Windows, the official script on Linux) and you re-run it after.
231
- Also the **Set up a free local model** button in Settings, where the download shows
232
- in the Agents tab and Symbiot switches over when it finishes.
233
-
234
234
  ## Connect it (once)
235
235
 
236
236
  Pick the AI you want it to write with:
@@ -270,34 +270,6 @@ It summarises the commits **you** authored — matching all your identities in e
270
270
  repo (per-repo and global email, your GitHub noreply address, your name), and
271
271
  counting everyone if that filter would drop almost all of an active repo's history.
272
272
 
273
- ## Your email, without an API: `symbiot mail`
274
-
275
- ```bash
276
- symbiot mail # what mail it can read, and what you sent this week
277
- symbiot mail --on # use it in week / standup (--off to stop)
278
- symbiot mail --add ~/Takeout/Mail/All\ mail.mbox # an export, or any mail folder
279
- ```
280
-
281
- Much of a week's work happens over email, so Week and Standup can include **what
282
- you sent**. There's no Gmail or Outlook API, no OAuth, no app to register and no
283
- password. Symbiot reads the mail a desktop client already keeps on your computer,
284
- so anyone can link theirs the same way:
285
-
286
- - **Thunderbird** (Linux, macOS, Windows, including Snap and Flatpak installs),
287
- **Apple Mail**, **Evolution**, **KMail**, and **mutt/neomutt** or any
288
- `~/Maildir` / `~/mail` are found automatically. Symbiot only looks at their
289
- **Sent** folders.
290
- - **Webmail only?** Add an export. Google Takeout gives you an `.mbox`, and Symbiot
291
- keeps just the messages with Gmail's *Sent* label (or from one of your
292
- addresses). You can also point it at any `.mbox` file or a folder of `.eml`
293
- files.
294
-
295
- It reads **headers only** (date, recipients, subject), never a message body. It's
296
- **off until you switch it on**, in Settings (*Email*) or with `symbiot mail --on`.
297
- **Preview** shows exactly what a write-up would see. On macOS, reading Apple Mail
298
- needs Full Disk Access for your terminal. Outlook for Windows keeps mail in `.pst`
299
- files, which Symbiot can't read, so export to `.mbox` or use Thunderbird.
300
-
301
273
  ## Where it looks
302
274
 
303
275
  By default Symbiot scans your **home folder**. To point it at where your work
@@ -327,7 +299,7 @@ instead of hanging. The CLI shows scan progress on one line as it goes.
327
299
 
328
300
  - **Reads:** your local git — commit messages and changed-file names, plus
329
301
  `TODO`/`FIXME` markers and uncommitted changes for `todo`. If you switch
330
- [email](#your-email-without-an-api-symbiot-mail) on, it also reads the headers
302
+ [email](#your-sent-email-without-an-api-symbiot-mail) on, it also reads the headers
331
303
  of mail you sent (subject, recipients, date). All local.
332
304
  - **Sends to the AI:** commit messages and dates, changed-file **names**, the
333
305
  folder structure, `TODO`/`FIXME` lines, and (for a repo review) an excerpt of
@@ -341,47 +313,70 @@ instead of hanging. The CLI shows scan progress on one line as it goes.
341
313
  - **Runs only what you set:** the agent command is yours, and deploy commands are
342
314
  read only from your own `~/.config/symbiot/`, never from a repo.
343
315
 
344
- ## Config
316
+ ## Experimental
317
+
318
+ These work and are tested, but are newer or depend on things Symbiot can't fully
319
+ check, so they may change. Everything above works without them.
320
+
321
+ ### Your sent email, without an API: `symbiot mail`
322
+
323
+ ```bash
324
+ symbiot mail # what mail it can read, and what you sent this week
325
+ symbiot mail --on # use it in week / standup (--off to stop)
326
+ symbiot mail --add ~/Takeout/Mail/All\ mail.mbox # an export, or any mail folder
327
+ ```
328
+
329
+ Week and Standup can include **what you sent**. It needs no Gmail or Outlook API,
330
+ OAuth, app registration or password: Symbiot reads the mail a desktop client
331
+ already keeps on your computer, so anyone can link theirs the same way.
332
+ **Thunderbird** (including Snap and Flatpak), **Apple Mail**, **Evolution**,
333
+ **KMail**, and **mutt/neomutt** or any `~/Maildir` / `~/mail` are found
334
+ automatically, and only their **Sent** folders are read. Webmail only? Add an
335
+ export: Google Takeout gives you an `.mbox`, and Symbiot keeps just the messages
336
+ with Gmail's *Sent* label (or from one of your addresses). Any `.mbox` file or
337
+ folder of `.eml` files works too.
338
+
339
+ It reads **headers only** (date, recipients, subject), never a message body, and
340
+ it's **off until you switch it on** in Settings (*Email*) or with
341
+ `symbiot mail --on`. **Preview** shows exactly what a write-up would see. Why it's
342
+ experimental: the tests cover mbox and Maildir, but it hasn't been tried against
343
+ real Apple Mail, Evolution or KMail stores. On macOS, reading Apple Mail needs Full
344
+ Disk Access for your terminal. Outlook for Windows (`.pst`) isn't supported:
345
+ export to `.mbox` or use Thunderbird.
346
+
347
+ ### CI status: `symbiot drift --ci`
345
348
 
346
- Everything lives in `~/.config/symbiot/`: `config.json` (your AI, `scanRoots`,
347
- `agentCmd`, `mail`; readable only by you), `tasks.json` (your tasks),
348
- `deploys.json` (optional, for drift's production check) and `rules.md` (optional
349
- conventions every repo review must respect, alongside each repo's own
350
- `CLAUDE.md` / `AGENTS.md` / `CONTRIBUTING.md`).
349
+ ```bash
350
+ symbiot drift --ci # also check GitHub Actions state (needs gh)
351
+ ```
351
352
 
352
- - `SYMBIOT_MODEL` — override the model for any provider (e.g. `gpt-4o`,
353
- `claude-haiku-4-5`, `gemini-1.5-pro`).
354
- - `SYMBIOT_SCAN_TIMEOUT` — the scan time limit, in seconds (default 60).
355
- - `SYMBIOT_PORT` — the app's port (default 7391).
356
- - `symbiot whoami` shows the active provider, model, and where the credential
357
- came from. `symbiot logout` forgets saved credentials.
353
+ Adds CI to the drift report, telling a real failing run apart from **CI that isn't
354
+ running at all** (jobs never started, usually a billing or spending limit) and
355
+ quoting GitHub's reason when it can. Also the **check CI** toggle in the Drift tab.
356
+ Why it's experimental: it needs the GitHub CLI (`gh`), only covers GitHub Actions,
357
+ and spotting "not running" is a heuristic.
358
358
 
359
- ## Development
359
+ ### Local models: `symbiot models` and `symbiot setup-local`
360
360
 
361
361
  ```bash
362
- npm test
362
+ symbiot models # recommend models for this machine
363
+ symbiot setup-local # the best local model for your RAM
364
+ symbiot setup-local --model llama3.2:3b # or pick one
363
365
  ```
364
366
 
365
- Runs four suites, all against throwaway repos and an isolated `HOME`:
366
-
367
- - `test/load.mjs` — the shipped files parse and load: `node --check` on each
368
- module, `ui.mjs` imported on its own with its page's JavaScript parsed, every
369
- local import listed in package.json `"files"`, and the `bin` entry point shipped.
370
- - `test/fixtures.mjs` — accuracy fixtures: the facts Symbiot collects (identity
371
- matching, stale checkouts, worktrees, drift) and the `symbiot drift` report as
372
- printed, the scan time limit, the agent handoff, and the review → approve cycle.
373
- - `test/smoke.mjs` — boots `symbiot app`, runs the page's own JavaScript against
374
- a fake DOM (every tab, every button), hits every endpoint the UI calls with the
375
- method the UI uses, and checks POST-only endpoints refuse GET and `/api` needs
376
- the token.
377
- - `test/install.mjs` — runs the CLI through a bin symlink, then `npm pack` +
378
- global install into a temp prefix: `symbiot help`, every shipped file present,
379
- and the installed `symbiot app` serving its page. Needs npm registry access for
380
- dependencies; `SYMBIOT_SKIP_INSTALL_TEST=1` skips that part.
381
-
382
- `SYMBIOT_NO_OPEN=1` stops `symbiot app` opening a window (the tests set it).
383
- Releases publish from a version tag via `.github/workflows/publish.yml`, and only
384
- after the full suite passes in CI.
367
+ `models` reads your RAM / CPU / GPU and recommends **local models by tier** (min /
368
+ med / max, marking which fit your RAM) to run free and private via
369
+ [Ollama](https://ollama.com), plus **paid** options (Claude / OpenAI / Gemini,
370
+ cheap → top). `setup-local` starts Ollama if it isn't running, downloads the model
371
+ with progress, and switches Symbiot over to it. If Ollama isn't installed it prints
372
+ the one-line install for your OS and you re-run it after. Both are buttons in
373
+ Settings, where the download shows in the Agents tab. You can always pick a model
374
+ yourself with `symbiot login`.
375
+
376
+ ## Config, development and releasing
377
+
378
+ Config files, environment variables (`SYMBIOT_MODEL`, `SYMBIOT_PORT`, …), the test
379
+ suites and how releases are cut are in [CONTRIBUTING.md](CONTRIBUTING.md).
385
380
 
386
381
  ---
387
382
 
package/agents.mjs ADDED
@@ -0,0 +1,308 @@
1
+ // Handing a repo to the user's agent: the saved command template, the one-click
2
+ // presets (and the Orca IDE one), the background-job registry behind the Agents
3
+ // tab, and the questions an unattended agent leaves for the user.
4
+ import { spawn } from "node:child_process";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, unlinkSync } from "node:fs";
8
+ import { randomBytes } from "node:crypto";
9
+ import { loadConfig, saveConfig, loadTasks, sh, hasCmd } from "./core.mjs";
10
+
11
+ const HANDOFFS = []; // live registry of agents Symbiot has handed work to
12
+ // ---- hand a repo (+ its tasks) to the user's agent — generic, settings-based
13
+ // ONE code path, whatever the agent. The handoff is a command TEMPLATE the user
14
+ // saves (`agentCmd`), with {dir} (repo path) and {prompt} (the task
15
+ // instruction), and it is the only thing that ever runs:
16
+ // handoffCmd() -> fillHandoff() -> runHandoff() -> track()
17
+ // Everything else in this section only helps pick a template: one-click presets
18
+ // for what's installed (detectHandoffs), the Orca preset (a template too, kept
19
+ // current by migrateOrcaCmd) and the cross-platform lookups behind them.
20
+ // claude -p "{prompt}" · aider --message "{prompt}" · code {dir}
21
+ // gnome-terminal --working-directory={dir} -- claude "{prompt}"
22
+ // Not tied to any one tool — you decide what runs.
23
+ const shSingle = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
24
+ const escDq = (s) => String(s).replace(/[\\"$`]/g, "\\$&");
25
+ // Plain words only: in the Orca preset this passes through two shells, so no
26
+ // backticks or $ (escDq covers one level).
27
+ 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.";
28
+ function handoffCmd() { const cfg = loadConfig(); return migrateOrcaCmd(migrateClaudeCmd(cfg.agentCmd)) || (cfg.ide ? `${cfg.ide} {dir}` : ""); } // ide = legacy
29
+ // Save the template ("" clears it). Either way the legacy `ide` key goes.
30
+ function setHandoffCmd(cmd) {
31
+ const cfg = loadConfig(); const v = String(cmd || "").trim();
32
+ if (v) cfg.agentCmd = v; else delete cfg.agentCmd;
33
+ delete cfg.ide; saveConfig(cfg);
34
+ return { ok: true, cmd: cfg.agentCmd || "" };
35
+ }
36
+ const fillHandoff = (tmpl, repoPath) => tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
37
+ // One agent per folder: two identical runs once started on the same repo 6s
38
+ // apart and raced each other. The registry catches a second click in this
39
+ // process; .symbiot/agent.pid catches another one (`symbiot push --open` while
40
+ // the app is up). Returns the job, null (no command set) or { busy, id, pid }.
41
+ // A preset that only opens a tab (Orca, editors) exits at once, so it holds
42
+ // the folder only that long.
43
+ function runHandoff(repoPath) {
44
+ const tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
45
+ const busy = runningHandoff(repoPath); if (busy) return { busy: true, id: busy.id || "", pid: busy.pid };
46
+ releaseHeldTasks(repoPath); // held for an agent another process started, which has since exited
47
+ const lock = join(repoPath, ".symbiot", LOCK);
48
+ const e = track(repoPath.split("/").pop(), fillHandoff(tmpl, repoPath), repoPath, () => {
49
+ try { if (JSON.parse(readFileSync(lock, "utf8")).pid === e.pid) unlinkSync(lock); } catch {}
50
+ releaseHeldTasks(repoPath);
51
+ });
52
+ if (!e) return null;
53
+ e.handoff = true;
54
+ if (e.pid) try { writeFileSync(lock, JSON.stringify({ pid: e.pid, id: e.id, startedAt: e.startedAt })); } catch {}
55
+ return e;
56
+ }
57
+ const LOCK = "agent.pid";
58
+ const LOCK_MAX_AGE = 12 * 3600 * 1000; // older than this, the pid has probably been reused
59
+ const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (err) { return !!err && err.code === "EPERM"; } };
60
+ // The agent still running in this folder, if any.
61
+ function runningHandoff(path) {
62
+ const e = HANDOFFS.find((x) => x.handoff && x.path === path && x.status === "running");
63
+ if (e) return { id: e.id, pid: e.pid, startedAt: e.startedAt };
64
+ try {
65
+ const l = JSON.parse(readSymbiot(path, LOCK));
66
+ if (l && Number.isInteger(l.pid) && Date.now() - l.startedAt < LOCK_MAX_AGE && pidAlive(l.pid)) return l;
67
+ } catch {}
68
+ return null;
69
+ }
70
+ // Send to repos while an agent is still running in the folder doesn't rewrite
71
+ // the TASKS.md it's working from: the new brief waits in TASKS.next.md and
72
+ // replaces TASKS.md once that agent exits. Returns true if it was held.
73
+ const HELD = "TASKS.next.md";
74
+ function writeTasks(path, md) {
75
+ const dir = join(path, ".symbiot"); mkdirSync(dir, { recursive: true });
76
+ if (runningHandoff(path)) { writeFileSync(join(dir, HELD), md); return true; }
77
+ writeFileSync(join(dir, "TASKS.md"), md);
78
+ try { unlinkSync(join(dir, HELD)); } catch {} // superseded by this brief
79
+ return false;
80
+ }
81
+ // Swap the held brief in, keeping the ticks the agent made meanwhile: a tick
82
+ // is how a task reaches review, so dropping one would lose that task's work.
83
+ function releaseHeldTasks(path) {
84
+ const held = readSymbiot(path, HELD); if (!held || runningHandoff(path)) return false;
85
+ const isTick = /^\s*-\s*\[x\]\s*/i, key = (l) => l.replace(/^\s*-\s*\[[ x]\]\s*/i, "").trim().toLowerCase();
86
+ const ticked = new Set(readSymbiot(path, "TASKS.md").split("\n").filter((l) => isTick.test(l)).map(key));
87
+ const md = held.split("\n").map((l) => /^\s*-\s*\[ \]/.test(l) && ticked.has(key(l)) ? l.replace("[ ]", "[x]") : l).join("\n");
88
+ try { writeFileSync(join(path, ".symbiot", "TASKS.md"), md); unlinkSync(join(path, ".symbiot", HELD)); return true; } catch { return false; }
89
+ }
90
+ // Presets. [cmd, label, macAppName] — macApp used to launch GUI editors on macOS
91
+ // where the CLI isn't on PATH (they're .app bundles).
92
+ const IDE_LIST = [["code", "VS Code", "Visual Studio Code"], ["cursor", "Cursor", "Cursor"], ["windsurf", "Windsurf", "Windsurf"], ["zed", "Zed", "Zed"], ["subl", "Sublime Text", "Sublime Text"], ["idea", "IntelliJ IDEA", "IntelliJ IDEA"], ["nvim", "Neovim", ""]];
93
+ // [cmd, label, template]. Only agents that leave changes to review: a handoff
94
+ // runs unattended, so a "plan only" run can't ask anything and leaves nothing to
95
+ // approve — that preset was dropped (a saved one still runs as-is).
96
+ // Claude: acceptEdits alone still blocks every shell command, so a run could
97
+ // never run its own tests and had to leave test work unticked. Allow just the
98
+ // test runner and node.
99
+ const CLAUDE_CMD = 'claude -p "{prompt}" --permission-mode acceptEdits --allowedTools "Bash(npm test:*)" "Bash(node:*)"';
100
+ const CLAUDE_CMD_OLD = 'claude -p "{prompt}" --permission-mode acceptEdits'; // ≤0.33
101
+ const AGENT_LIST = [
102
+ ["claude", "Claude Code — make changes", CLAUDE_CMD],
103
+ ["codex", "Codex (OpenAI/GPT) — make changes", 'codex exec --full-auto "{prompt}"'],
104
+ ["aider", "Aider — make changes", 'aider --message "{prompt}" --yes'],
105
+ ["gemini", "Gemini — make changes", 'gemini --yolo -p "{prompt}"'],
106
+ ["cursor-agent", "Cursor agent", 'cursor-agent -p "{prompt}"'],
107
+ ];
108
+ function detectHandoffs() {
109
+ const editors = [];
110
+ for (const [cmd, label, app] of IDE_LIST) {
111
+ if (hasCmd(cmd)) editors.push({ label, tmpl: `${cmd} {dir}`, kind: "editor" });
112
+ else { const a = macApp(app); if (a) editors.push({ label, tmpl: `open -a ${JSON.stringify(a)} {dir}`, kind: "editor" }); }
113
+ }
114
+ const agents = AGENT_LIST.filter(([cmd]) => hasCmd(cmd)).map(([cmd, label, tmpl]) => ({ label, tmpl, kind: "agent" }));
115
+ // Orca IDE (any OS): register the repo + open a terminal tab. Two variants —
116
+ // one that just opens the repo (use Orca's own agent, e.g. GPT), and one that
117
+ // runs Claude in the tab for Claude users. Not Claude-only.
118
+ // `<orca> open` first: it launches Orca AND blocks until the runtime is
119
+ // reachable, so this works even when Orca is closed (its CLI can't talk to a
120
+ // dead app). Use the full orca-ide path, never bare `orca` (that's a different
121
+ // tool on PATH). If Orca is already up, `open` returns fast.
122
+ const orca = findOrcaCli();
123
+ if (orca) {
124
+ const q = JSON.stringify(orca);
125
+ agents.unshift(
126
+ { label: "Orca IDE — open repo (use your Orca agent)", tmpl: orcaHandoffCmd(q, ""), kind: "agent" },
127
+ { label: "Orca IDE — run Claude in a tab", tmpl: orcaHandoffCmd(q, ORCA_CLAUDE_CMD), kind: "agent" },
128
+ );
129
+ }
130
+ return { agents, editors };
131
+ }
132
+ // The Orca preset. Build the full Orca handoff, cold-start safe. `bin` is the
133
+ // quoted orca-ide path; `commandPart` is e.g. ORCA_CLAUDE_CMD or "" (open only).
134
+ // open launches Orca & waits for the runtime to be REACHABLE, but on a cold
135
+ // start the workspace graph isn't ready yet (runtime.state=graph_not_ready) and
136
+ // `terminal create` times out — so we poll `status` until state=ready, then add
137
+ // the repo and create the terminal (retry: the worktree can lag a beat behind).
138
+ // {prompt} needs its own (escaped) quotes: the outer shell strips the --command
139
+ // quotes and Orca re-runs the string in the tab, so an unquoted prompt reached
140
+ // the agent as just its first word ("Read"). Same test-runner rules as
141
+ // CLAUDE_CMD, so a run in the tab can test its own work too.
142
+ const ORCA_CLAUDE_INNER = `claude \\"{prompt}\\" --allowedTools \\"Bash(npm test:*)\\" \\"Bash(node:*)\\"`;
143
+ const ORCA_CLAUDE_INNER_OLD = `claude \\"{prompt}\\"`; // ≤0.34
144
+ const ORCA_CLAUDE_CMD = ` --command "${ORCA_CLAUDE_INNER}"`;
145
+ function orcaHandoffCmd(bin, commandPart) {
146
+ const waitReady = `for i in $(seq 1 40); do ${bin} status --json 2>/dev/null | grep -q '"state": *"ready"' && break; sleep 1; done`;
147
+ const mkTerm = `for j in 1 2 3; do ${bin} terminal create --worktree path:{dir}${commandPart} --focus && break; sleep 2; done`;
148
+ return `${bin} open; ${waitReady}; ${bin} repo add --path {dir}; ${mkTerm}`;
149
+ }
150
+ // Normalise an Orca command saved by an older version (no launch / no wait-for-
151
+ // ready) to the current cold-start-safe form, preserving its binary path and any
152
+ // custom `--command`. Idempotent: already-current commands are left untouched.
153
+ function migrateOrcaCmd(cmd) {
154
+ if (!cmd || typeof cmd !== "string") return cmd;
155
+ if (!/orca-ide/.test(cmd) || !/\brepo add\b/.test(cmd)) return cmd;
156
+ const cm = cmd.match(/--command\s+"((?:[^"\\]|\\.)*)"/); // preserve a custom agent command
157
+ // ≤0.26 saved a bare {prompt} here (truncated to one word) — quote it; the
158
+ // ≤0.34 Claude preset (exact match only) gains the test-runner rules
159
+ const quoted = cm ? cm[1].replace(/(^|\s)\{prompt\}(?=\s|$)/g, '$1\\"{prompt}\\"') : "";
160
+ const inner = quoted === ORCA_CLAUDE_INNER_OLD ? ORCA_CLAUDE_INNER : quoted;
161
+ if (/status --json/.test(cmd) && /grep -q/.test(cmd) && (!cm || inner === cm[1])) return cmd; // already current
162
+ const bm = cmd.match(/^\s*("[^"]*"|'[^']*'|\S+)/); // leading orca-ide binary token
163
+ const bin = bm ? bm[1] : "";
164
+ const commandPart = cm ? ` --command "${inner}"` : "";
165
+ const rebuilt = bin ? orcaHandoffCmd(bin, commandPart) : cmd;
166
+ if (rebuilt !== cmd) { try { const cfg = loadConfig(); if (cfg.agentCmd === cmd) { cfg.agentCmd = rebuilt; saveConfig(cfg); } } catch {} }
167
+ return rebuilt;
168
+ }
169
+ // Upgrade a saved Claude preset from ≤0.33 to the current one. Exact match only:
170
+ // a command the user edited is theirs and runs as-is.
171
+ function migrateClaudeCmd(cmd) {
172
+ if (typeof cmd !== "string" || cmd.trim() !== CLAUDE_CMD_OLD) return cmd;
173
+ try { const cfg = loadConfig(); if (cfg.agentCmd === cmd) { cfg.agentCmd = CLAUDE_CMD; saveConfig(cfg); } } catch {}
174
+ return CLAUDE_CMD;
175
+ }
176
+ // Cross-platform detection for the presets (hasCmd lives in core.mjs).
177
+ function macApp(name) { if (process.platform !== "darwin" || !name) return ""; for (const base of ["/Applications", join(homedir(), "Applications")]) { try { if (existsSync(join(base, name + ".app"))) return name; } catch {} } return ""; }
178
+ // Find the Orca IDE CLI across OSes (known locations, then a bounded search).
179
+ let ORCA_CLI; // cached per process: undefined=unchecked, ""=none, string=path
180
+ function findOrcaCli() {
181
+ if (ORCA_CLI !== undefined) return ORCA_CLI;
182
+ ORCA_CLI = _findOrcaCli();
183
+ return ORCA_CLI;
184
+ }
185
+ function _findOrcaCli() {
186
+ const home = homedir(); const cands = [];
187
+ if (process.platform === "linux") cands.push(join(home, ".local/share/orca-ide/app/resources/bin/orca-ide"));
188
+ if (process.platform === "darwin") { cands.push(join(home, "Library/Application Support/orca-ide/app/resources/bin/orca-ide"), "/Applications/Orca.app/Contents/Resources/app/resources/bin/orca-ide", join(home, "Applications/Orca.app/Contents/Resources/app/resources/bin/orca-ide")); }
189
+ if (process.platform === "win32") { const la = process.env.LOCALAPPDATA || ""; cands.push(join(la, "orca-ide", "app", "resources", "bin", "orca-ide"), join(la, "Programs", "orca-ide", "resources", "app", "resources", "bin", "orca-ide")); }
190
+ for (const c of cands) { try { if (existsSync(c)) return c; } catch {} }
191
+ const roots = process.platform === "darwin" ? [join(home, "Library/Application Support"), "/Applications", join(home, "Applications")]
192
+ : process.platform === "win32" ? [process.env.LOCALAPPDATA || "", process.env.PROGRAMFILES || ""]
193
+ : [join(home, ".local/share"), "/opt", join(home, ".config")];
194
+ for (const r of roots) { if (!r) continue; const hit = sh(`find ${JSON.stringify(r)} -maxdepth 6 -name orca-ide -type f 2>/dev/null | head -1`).trim(); if (hit) return hit; }
195
+ return "";
196
+ }
197
+ // ---- background jobs --------------------------------------------------------
198
+ // Run a shell command as a tracked, logged background job that shows up live in
199
+ // the Agents tab. Shared by the agent handoff and the local-model setup.
200
+ function track(name, cmd, cwd, onExit) {
201
+ try {
202
+ const dir = join(cwd, ".symbiot"); mkdirSync(dir, { recursive: true });
203
+ const logp = join(dir, "agent.log");
204
+ let fd = "ignore"; try { fd = openSync(logp, "a"); writeSync(fd, `\n=== ${name} ${new Date().toISOString()} ===\n$ ${cmd}\n`); } catch {}
205
+ const entry = { id: randomBytes(4).toString("hex"), name, path: cwd, log: logp, startedAt: Date.now(), status: "running", exitCode: null, endedAt: null };
206
+ const child = spawn(cmd, { shell: true, cwd, detached: true, stdio: ["ignore", fd === "ignore" ? "ignore" : fd, fd === "ignore" ? "ignore" : fd] });
207
+ entry.pid = child.pid;
208
+ child.on("exit", (code) => { entry.status = code === 0 ? "done" : "failed"; entry.exitCode = code; entry.endedAt = Date.now(); if (onExit) try { onExit(code); } catch {} });
209
+ child.on("error", () => { entry.status = "failed"; entry.endedAt = Date.now(); });
210
+ child.unref();
211
+ HANDOFFS.unshift(entry);
212
+ if (HANDOFFS.length > 30) HANDOFFS.length = 30;
213
+ return entry;
214
+ } catch { return null; }
215
+ }
216
+ // Agent-agnostic "what did it do": read it straight from git, so it works the
217
+ // same whoever the agent was (Claude, Codex/GPT, Aider, Gemini, Cursor…). Shows
218
+ // current working-tree changes + any commits the agent made since it started.
219
+ function agentChanges(path, startedAt) {
220
+ try {
221
+ const q = JSON.stringify(path);
222
+ const porcelain = sh(`git -C ${q} status --porcelain`).trim();
223
+ // Don't count Symbiot's own .symbiot/ dir (agent.log, TASKS.md) as the agent's work.
224
+ const dirty = porcelain ? porcelain.split("\n").filter((l) => { const p = l.slice(3); return p !== ".symbiot" && p !== ".symbiot/" && p.indexOf(".symbiot/") !== 0; }).length : 0;
225
+ const stat = (sh(`git -C ${q} diff --shortstat`).trim() || sh(`git -C ${q} diff --cached --shortstat`).trim()).replace(/^\s+/, "");
226
+ const since = new Date(startedAt || Date.now()).toISOString();
227
+ const raw = sh(`git -C ${q} log --since=${JSON.stringify(since)} --pretty=%h\u0001%s`).trim();
228
+ const commits = raw ? raw.split("\n").slice(0, 8).map((l) => { const i = l.indexOf("\u0001"); return { hash: l.slice(0, i), msg: l.slice(i + 1) }; }) : [];
229
+ return { dirty, stat, commits };
230
+ } catch { return { dirty: 0, stat: "", commits: [] }; }
231
+ }
232
+ // ---- agent questions: decisions, options and ideas, from ANY agent ---------
233
+ // A handoff runs unattended (claude -p, codex exec, aider --message, …), so the
234
+ // agent can't stop and ask in chat. TASKS.md tells it to write
235
+ // .symbiot/QUESTIONS.md instead — questions with options, plus ideas — which
236
+ // the Agents tab shows on that agent's block. The user's answers are appended to
237
+ // .symbiot/ANSWERS.md for the next run to read. Plain files, so it works the
238
+ // same whichever model or tool the agent is.
239
+ const QUESTIONS_MAX = 5;
240
+ const qKey = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ");
241
+ const readSymbiot = (path, f) => { try { return readFileSync(join(path, ".symbiot", f), "utf8"); } catch { return ""; } };
242
+ // "## Questions" → "### question", context lines, "- option" bullets;
243
+ // "## Suggestions" (or Ideas / Follow-ups) → "- idea" bullets. Forgiving: a bare
244
+ // bullet under Questions is a question with no options.
245
+ function parseQuestions(md) {
246
+ const questions = [], suggestions = []; let sec = "q", cur = null;
247
+ const clip = (s) => String(s).trim().slice(0, 300);
248
+ for (const raw of String(md || "").split(/\r?\n/)) {
249
+ const l = raw.trim(); if (!l) continue;
250
+ let m;
251
+ if ((m = l.match(/^###\s+(.+)$/))) {
252
+ if (sec === "s") { suggestions.push(clip(m[1])); cur = null; }
253
+ else { cur = { q: clip(m[1]), context: "", options: [] }; questions.push(cur); }
254
+ continue;
255
+ }
256
+ if ((m = l.match(/^##\s+(.+)$/))) { sec = /suggest|idea|follow|option/i.test(m[1]) ? "s" : "q"; cur = null; continue; }
257
+ if (/^#\s/.test(l)) continue;
258
+ const b = l.match(/^(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.+)$/);
259
+ if (sec === "s") { if (b) suggestions.push(clip(b[1])); continue; }
260
+ if (b) { if (cur) cur.options.push(clip(b[1])); else questions.push({ q: clip(b[1]), context: "", options: [] }); continue; }
261
+ if (cur) cur.context = clip((cur.context ? cur.context + " " : "") + l);
262
+ }
263
+ 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) };
264
+ }
265
+ // The OPEN questions (not yet in ANSWERS.md) and the agent's ideas, each marked
266
+ // if it's already on the task list for this repo.
267
+ function agentQuestions(path, repo) {
268
+ const p = parseQuestions(readSymbiot(path, "QUESTIONS.md"));
269
+ const done = new Set([...readSymbiot(path, "ANSWERS.md").matchAll(/^###\s+(.+)$/gm)].map((m) => qKey(m[1])));
270
+ const open = p.questions.filter((x) => !done.has(qKey(x.q)));
271
+ const tasks = p.suggestions.length ? loadTasks() : [];
272
+ return {
273
+ questions: open.slice(0, QUESTIONS_MAX), answered: p.questions.length - open.length,
274
+ suggestions: p.suggestions.map((text) => ({ text, added: tasks.some((t) => t.repo === repo && qKey(t.text) === qKey(text)) })),
275
+ };
276
+ }
277
+ // Save answers to .symbiot/ANSWERS.md; opts.rerun hands the repo back to the
278
+ // agent (same saved command) so it carries on with them.
279
+ function answerQuestions(path, answers, opts = {}) {
280
+ path = String(path || "");
281
+ if (!path || !HANDOFFS.some((e) => e.path === path)) return { error: "No agent has run in that folder." };
282
+ const open = new Map(agentQuestions(path, "").questions.map((x) => [qKey(x.q), x.q]));
283
+ const rows = (Array.isArray(answers) ? answers : [])
284
+ .map((x) => ({ q: open.get(qKey(x && x.q)), a: String((x && x.a) || "").replace(/^\s*#+/gm, "").trim().slice(0, 2000) }))
285
+ .filter((x) => x.q && x.a);
286
+ if (!rows.length) return { error: "Pick or type at least one answer." };
287
+ 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";
288
+ const day = new Date().toISOString().slice(0, 10);
289
+ try {
290
+ mkdirSync(join(path, ".symbiot"), { recursive: true });
291
+ writeFileSync(join(path, ".symbiot", "ANSWERS.md"), prev.replace(/\s*$/, "\n") + rows.map((x) => `\n### ${x.q}\n${x.a}\n_answered ${day}_\n`).join(""));
292
+ } catch (e) { return { error: "Couldn't write ANSWERS.md: " + ((e && e.message) || e) }; }
293
+ const out = { ok: true, saved: rows.length };
294
+ if (opts.rerun) { const e = runHandoff(path); if (e && e.busy) out.note = "Answers saved. An agent is still running in that folder, so another wasn't started. Send them again once it finishes."; else 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."; }
295
+ return out;
296
+ }
297
+ // The Agents tab: every tracked job, its log tail, what it changed, and — on the
298
+ // newest job per folder — the questions/ideas it left.
299
+ function agentsList() {
300
+ const seen = new Set();
301
+ return HANDOFFS.map((e) => {
302
+ let tail = ""; try { tail = readFileSync(e.log, "utf8").slice(-1200); } catch {}
303
+ const first = !seen.has(e.path); seen.add(e.path);
304
+ 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 };
305
+ });
306
+ }
307
+
308
+ export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, fillHandoff, runHandoff, runningHandoff, writeTasks, releaseHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, agentQuestions, answerQuestions, agentsList };