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 +103 -108
- package/agents.mjs +308 -0
- package/core.mjs +53 -0
- package/drift.mjs +124 -0
- package/index.mjs +71 -422
- package/package.json +5 -2
- package/ui.mjs +22 -14
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
|
|
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"
|
|
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
|
|
75
|
-
[
|
|
76
|
-
|
|
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
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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**.
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
359
|
+
### Local models: `symbiot models` and `symbiot setup-local`
|
|
360
360
|
|
|
361
361
|
```bash
|
|
362
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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 };
|