synartesis 0.9.0 → 0.9.2
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/CHANGELOG.md +111 -0
- package/README.md +55 -17
- package/dist/{chunk-AEEKBR5D.js → chunk-MTPUG26S.js} +135 -16
- package/dist/cli.js +230 -39
- package/dist/proxy.js +22 -11
- package/package.json +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,117 @@
|
|
|
2
2
|
|
|
3
3
|
What changed, and why it mattered. Dates are release dates.
|
|
4
4
|
|
|
5
|
+
## 0.9.2 — 2026-09-24
|
|
6
|
+
|
|
7
|
+
For someone who has never used a terminal. Every screen was walked through as a
|
|
8
|
+
new user would meet it, in a real terminal, from a fresh machine. Each place
|
|
9
|
+
they would have got stuck is fixed.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`synartesis clean`**: one command, no questions to answer first. It clears
|
|
14
|
+
every finished session with nothing left to undo, and closes sessions left
|
|
15
|
+
open by an app that has since exited. It keeps anything you could still undo,
|
|
16
|
+
anything waiting for you, and anything whose outcome is unknown. Your
|
|
17
|
+
connected apps and your policy are not touched. It shows what it will do and
|
|
18
|
+
asks once. On a journal three weeks old it cleared 119 of 126 sessions.
|
|
19
|
+
`list` suggests it once there is clutter worth clearing.
|
|
20
|
+
- Sessions now record which process opened them, so one left open by an app
|
|
21
|
+
that crashed or was force-quit can be told apart from one still in use.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **The first screen was a dead end.** `synartesis` on a fresh machine said
|
|
26
|
+
"point your client at it" and accepted no keys. It now opens on the AI apps
|
|
27
|
+
found and their tools: `a` covers all of them, `enter` covers one. When the
|
|
28
|
+
first session arrives it switches to the sessions by itself.
|
|
29
|
+
- **`watch` ignored keys.** With nothing waiting it showed no keys at all, not
|
|
30
|
+
even how to quit, and silently swallowed every key pressed. It always shows
|
|
31
|
+
its keys now, says so when there is nothing to approve yet, and answers a key
|
|
32
|
+
that does nothing instead of ignoring it. The console does the same.
|
|
33
|
+
- **Opening `synartesis` because of a notification landed on the wrong
|
|
34
|
+
screen.** Approving was a screen away, behind a key labelled "held", so `a`
|
|
35
|
+
did nothing. It opens on the waiting call now, shows why it was held, and
|
|
36
|
+
flags new ones from any other screen.
|
|
37
|
+
- **The notification said to type an approve command** with an id and an
|
|
38
|
+
absolute path, cut off before its end. It now says to open a terminal and run
|
|
39
|
+
`synartesis`.
|
|
40
|
+
- After covering apps from the screen, it names which apps to quit and reopen,
|
|
41
|
+
and says why any server was not covered. It used to say "see the reasons
|
|
42
|
+
above" on a screen with nothing above.
|
|
43
|
+
- The proxy wrote a routine schema note into the client's log as a warning, once
|
|
44
|
+
per tool on every start (fourteen for the filesystem server). It is debug
|
|
45
|
+
level now.
|
|
46
|
+
|
|
47
|
+
### Docs
|
|
48
|
+
|
|
49
|
+
- The README's Install section is three steps for somebody who has never used a
|
|
50
|
+
terminal, then what to do when something needs your OK, how to undo, how to
|
|
51
|
+
clean up, and how to take it all out.
|
|
52
|
+
|
|
53
|
+
## 0.9.1 — 2026-09-24
|
|
54
|
+
|
|
55
|
+
This release attacks undo instead of describing it. The new tests generate the
|
|
56
|
+
agent sessions rather than script them, kill undo with SIGKILL partway through,
|
|
57
|
+
and race several agents against one approval. They found five bugs that no
|
|
58
|
+
hand-written scenario had reached.
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- **An undo that succeeded could be stuck for good.** A server that applies an
|
|
63
|
+
inverse and then fails before answering (a crashed handler, a gateway timeout)
|
|
64
|
+
was read as having refused it. The next attempt found the resource already put
|
|
65
|
+
back, called that drift, and every attempt after that refused on it. Undo now
|
|
66
|
+
reads the resource when an inverse errors, and counts it as done when the
|
|
67
|
+
resource is where that inverse puts it.
|
|
68
|
+
- **An undo killed at the wrong moment could block that action for ever.**
|
|
69
|
+
Claiming an action and recording which process holds it were two separate
|
|
70
|
+
writes, though the code's own comment said they were one transaction. A kill
|
|
71
|
+
between them left an action claimed by nobody, which no later undo could tell
|
|
72
|
+
from one still in progress. They are now one transaction.
|
|
73
|
+
- **An undo killed while compensating stopped on its own success.** The delete
|
|
74
|
+
that undoes a create went through, and the resumed undo called the record's
|
|
75
|
+
absence drift. It now recognises it, but only when the owner is certainly
|
|
76
|
+
gone and the record is certainly gone. An edit a person made instead still
|
|
77
|
+
stops it.
|
|
78
|
+
- **Running `install` from your home directory covered servers twice.** There,
|
|
79
|
+
a client's project config and its global one are the same file. It was listed
|
|
80
|
+
twice, and every server in it was drafted into the policy a second time under
|
|
81
|
+
another name.
|
|
82
|
+
- **"Covered, nothing through it yet" about a server in daily use.** The connect
|
|
83
|
+
screen looked up the last use under the client entry's name, not the name the
|
|
84
|
+
policy gives the server, and those differ when two clients list a server with
|
|
85
|
+
the same name.
|
|
86
|
+
- **A policy reading a field every object inherits** (`constructor`,
|
|
87
|
+
`toString`) resolved to nothing when the record lacked it, instead of saying
|
|
88
|
+
it was absent. The inverse then quietly left that field out of what it
|
|
89
|
+
restored.
|
|
90
|
+
|
|
91
|
+
### Faster
|
|
92
|
+
|
|
93
|
+
- **Servers start together.** Every path that starts more than one server
|
|
94
|
+
(a proxy serving several, `undo`, `check`, `pin`) waited for each before
|
|
95
|
+
starting the next. Three servers that take 800 ms each answered after 2.9 s;
|
|
96
|
+
now it is under 1.6 s, and the order they are reported in is unchanged.
|
|
97
|
+
- **Each server's tools are listed once at start-up**, not three times.
|
|
98
|
+
|
|
99
|
+
### Tests
|
|
100
|
+
|
|
101
|
+
- `tests/stress-undo.test.ts`: random sessions with awkward values (unicode,
|
|
102
|
+
5,000-character strings, `__proto__`, text that looks like a template), undone
|
|
103
|
+
through injected crashes before and after writes, undone to random steps, and
|
|
104
|
+
with a person's edit that must survive.
|
|
105
|
+
- `tests/kill-undo.test.ts`: `synartesis undo` killed with SIGKILL the moment a
|
|
106
|
+
random number of actions have been undone, again and again, with a real server
|
|
107
|
+
and store.
|
|
108
|
+
- `tests/race-approval.test.ts`: eight separate agent processes retrying one
|
|
109
|
+
approved email in the same instant. Exactly one is sent.
|
|
110
|
+
- Direct tests for the code only child processes reached before: which clients
|
|
111
|
+
are covered, the environment an undo starts a server with, and the Linux
|
|
112
|
+
notifier.
|
|
113
|
+
- `pnpm stress` runs all three at full strength; `pnpm coverage` reports what
|
|
114
|
+
the suite reaches.
|
|
115
|
+
|
|
5
116
|
## 0.9.0 — 2026-09-24
|
|
6
117
|
|
|
7
118
|
0.8.4 to 0.8.8 made Synartesis correct once you were using it. This release is
|
package/README.md
CHANGED
|
@@ -92,23 +92,48 @@ and `--replan`, or `--force` to overwrite deliberately.
|
|
|
92
92
|
---
|
|
93
93
|
## Install
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
95
|
+
**Three steps, if you have never used a terminal.** Open the Terminal app
|
|
96
|
+
(on a Mac: press ⌘ Space, type *Terminal*, press Return), then:
|
|
97
|
+
|
|
98
|
+
1. If `npm --version` says *command not found*, install Node.js from
|
|
99
|
+
[nodejs.org](https://nodejs.org) first (the LTS button), and open a new
|
|
100
|
+
Terminal window.
|
|
101
|
+
2. Install Synartesis:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
npm install -g synartesis
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
3. Start it:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
synartesis
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
It lists the AI apps on your computer and the tools they use. Press **a** to
|
|
114
|
+
cover all of them, then **quit and reopen** those apps (it names them).
|
|
115
|
+
|
|
116
|
+
That is the whole setup. From then on:
|
|
117
|
+
|
|
118
|
+
- **Something needs your OK.** A notification says a call is waiting. Open
|
|
119
|
+
Terminal, type `synartesis`: it opens on that call. **a** approves, **d**
|
|
120
|
+
denies, **A** approves and stops asking about that tool for an hour.
|
|
121
|
+
- **Undo what an agent did.** Type `synartesis`, press **r** if it is showing
|
|
122
|
+
waiting calls, move to the session with the arrow keys, press **u**, then
|
|
123
|
+
**y**. Press **p** first to see what it would do without changing anything.
|
|
124
|
+
- **Too many old sessions.** `synartesis clean` clears the finished ones and
|
|
125
|
+
keeps anything you could still undo or still need to decide.
|
|
126
|
+
- **Take it all back out.** `synartesis uninstall` restores every app's
|
|
127
|
+
settings exactly as they were.
|
|
128
|
+
|
|
129
|
+
The same thing without the screen: `synartesis install`. It finds what Claude
|
|
130
|
+
Code, Claude Desktop, Cursor, Codex, Gemini CLI, Copilot CLI, Antigravity or
|
|
131
|
+
Devin Desktop (Windsurf) already list, writes one policy covering all of it,
|
|
132
|
+
and points each entry at the proxy. Servers it recognises get the policy that
|
|
133
|
+
ships for them and work immediately; the rest are drafted with every tool held
|
|
134
|
+
until you say how to undo it. Your config is copied aside first,
|
|
135
|
+
`synartesis uninstall` puts it back, and `synartesis status` says what is
|
|
136
|
+
covered.
|
|
112
137
|
|
|
113
138
|
Each server keeps its own entry and its own proxy, so **no tool is renamed** —
|
|
114
139
|
the agent sees exactly the names it saw before. **Your agent needs nothing
|
|
@@ -487,6 +512,7 @@ get it if it is not installed.
|
|
|
487
512
|
| `undo <id> --replan` | Rebuild each undo from the current manifest |
|
|
488
513
|
| `undo <id> --force [--yes]` | Print what it would write over; `--yes` goes ahead |
|
|
489
514
|
| `watch` | Live activity, with approvals answerable in place |
|
|
515
|
+
| `clean` | Clear finished sessions; keep anything you could undo or still need to decide |
|
|
490
516
|
| `prune` | Delete sessions older than 30 days and reclaim the space |
|
|
491
517
|
| `close [id]` | End a session a killed proxy left open |
|
|
492
518
|
|
|
@@ -609,6 +635,18 @@ pnpm check
|
|
|
609
635
|
Every push runs that on Linux and macOS across Node 22 and 24, plus both demos,
|
|
610
636
|
the installer, and a build of the desktop app.
|
|
611
637
|
|
|
638
|
+
```bash
|
|
639
|
+
pnpm stress
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
The same three attacks `check` runs lightly, at full strength: 500 random agent
|
|
643
|
+
sessions, each undone and checked byte for byte against where it started (also
|
|
644
|
+
undone to a random step, and with a person's edit made afterwards that must
|
|
645
|
+
survive); `synartesis undo` killed with SIGKILL at random points 25 times over
|
|
646
|
+
and run again; and eight agents racing one approval, ten times, which must send
|
|
647
|
+
exactly one email. A failure names its seed, and `SYNARTESIS_STRESS_SEED`
|
|
648
|
+
replays it. `pnpm coverage` reports what the suite reaches.
|
|
649
|
+
|
|
612
650
|
**Windows is built and not tested.** The release attaches a Windows installer,
|
|
613
651
|
and no CI job compiles or exercises it — the test matrix is Linux and macOS. It
|
|
614
652
|
is expected to work, the code has no platform-specific paths outside
|
|
@@ -31,10 +31,10 @@ function shown(name) {
|
|
|
31
31
|
function unmarked(text) {
|
|
32
32
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
33
33
|
}
|
|
34
|
-
function words(notice) {
|
|
34
|
+
function words(notice, open) {
|
|
35
35
|
return {
|
|
36
36
|
title: "Synartesis: a call is waiting for you",
|
|
37
|
-
body: `${shown(notice.server)}.${shown(notice.tool)} -- ${notice.approve}`
|
|
37
|
+
body: `${shown(notice.server)}.${shown(notice.tool)} -- open a terminal and run: ${open ?? notice.approve}`
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
40
|
var KILL_AFTER_MS = 5e3;
|
|
@@ -51,14 +51,13 @@ function launch(command, args) {
|
|
|
51
51
|
} catch {
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
-
function desktopNotifier(env = process.env) {
|
|
54
|
+
function desktopNotifier(env = process.env, os = platform(), open) {
|
|
55
55
|
if (env["SYNARTESIS_NOTIFY"] === "0") {
|
|
56
56
|
return SILENT;
|
|
57
57
|
}
|
|
58
|
-
const os = platform();
|
|
59
58
|
if (os === "darwin") {
|
|
60
59
|
return (notice) => {
|
|
61
|
-
const { title, body } = words(notice);
|
|
60
|
+
const { title, body } = words(notice, open);
|
|
62
61
|
launch("osascript", [
|
|
63
62
|
"-e",
|
|
64
63
|
"on run argv",
|
|
@@ -74,7 +73,7 @@ function desktopNotifier(env = process.env) {
|
|
|
74
73
|
}
|
|
75
74
|
if (os === "linux") {
|
|
76
75
|
return (notice) => {
|
|
77
|
-
const { title, body } = words(notice);
|
|
76
|
+
const { title, body } = words(notice, open);
|
|
78
77
|
launch("notify-send", ["--app-name=Synartesis", "--", title, unmarked(body)]);
|
|
79
78
|
};
|
|
80
79
|
}
|
|
@@ -440,6 +439,19 @@ CREATE TABLE IF NOT EXISTS allows (
|
|
|
440
439
|
);
|
|
441
440
|
CREATE INDEX IF NOT EXISTS allows_current ON allows(server, tool, until);
|
|
442
441
|
|
|
442
|
+
-- Which process opened each session, so a session left open by an app that
|
|
443
|
+
-- has since exited can be told from one still in use. Without it, closing an
|
|
444
|
+
-- abandoned session was a person's guess (synartesis close), and every
|
|
445
|
+
-- session a crashed or force-quit app left behind stayed "still open" in the
|
|
446
|
+
-- list for ever. host and pid for the reason leases have both.
|
|
447
|
+
--
|
|
448
|
+
-- Not a schema version bump, for the reason the tables above are not.
|
|
449
|
+
CREATE TABLE IF NOT EXISTS run_owners (
|
|
450
|
+
run_id TEXT PRIMARY KEY REFERENCES runs(id),
|
|
451
|
+
host TEXT NOT NULL,
|
|
452
|
+
pid INTEGER NOT NULL
|
|
453
|
+
);
|
|
454
|
+
|
|
443
455
|
CREATE INDEX IF NOT EXISTS actions_by_run ON actions(run_id, seq);
|
|
444
456
|
|
|
445
457
|
-- Deliberately not a schema version bump. Adding an index changes no row and
|
|
@@ -753,7 +765,10 @@ var SqliteJournal = class {
|
|
|
753
765
|
beginRun(label) {
|
|
754
766
|
const id = crypto.randomUUID();
|
|
755
767
|
this.#run("beginRun", () => {
|
|
756
|
-
this.#db.
|
|
768
|
+
this.#db.transaction(() => {
|
|
769
|
+
this.#db.prepare("INSERT INTO runs (id, label, started_at, status) VALUES (?, ?, ?, 'active')").run(id, label ?? null, (/* @__PURE__ */ new Date()).toISOString());
|
|
770
|
+
this.#db.prepare("INSERT INTO run_owners (run_id, host, pid) VALUES (?, ?, ?)").run(id, hostname(), process.pid);
|
|
771
|
+
})();
|
|
757
772
|
});
|
|
758
773
|
return id;
|
|
759
774
|
}
|
|
@@ -901,7 +916,7 @@ var SqliteJournal = class {
|
|
|
901
916
|
* resource is contested.
|
|
902
917
|
*/
|
|
903
918
|
markRollingBack(actionId, from = ["applied"]) {
|
|
904
|
-
return this.#run("markRollingBack", () => {
|
|
919
|
+
return this.#run("markRollingBack", () => this.#db.transaction(() => {
|
|
905
920
|
const slots = from.map(() => "?").join(",");
|
|
906
921
|
const result = this.#db.prepare(
|
|
907
922
|
`UPDATE actions SET status = 'rolling_back' WHERE id = ? AND status IN (${slots})`
|
|
@@ -915,7 +930,7 @@ var SqliteJournal = class {
|
|
|
915
930
|
pid = excluded.pid, claimed_at = excluded.claimed_at`
|
|
916
931
|
).run(actionId, hostname(), process.pid, (/* @__PURE__ */ new Date()).toISOString());
|
|
917
932
|
return true;
|
|
918
|
-
});
|
|
933
|
+
}).immediate());
|
|
919
934
|
}
|
|
920
935
|
leaseFor(actionId) {
|
|
921
936
|
return this.#run("leaseFor", () => {
|
|
@@ -1231,6 +1246,64 @@ var SqliteJournal = class {
|
|
|
1231
1246
|
close() {
|
|
1232
1247
|
this.#db.close();
|
|
1233
1248
|
}
|
|
1249
|
+
cleanable(idleBefore) {
|
|
1250
|
+
return this.#run("cleanable", () => {
|
|
1251
|
+
const here = hostname();
|
|
1252
|
+
const open = z.array(
|
|
1253
|
+
z.object({
|
|
1254
|
+
id: z.string(),
|
|
1255
|
+
host: z.string().nullable(),
|
|
1256
|
+
pid: z.number().nullable(),
|
|
1257
|
+
last: z.string()
|
|
1258
|
+
})
|
|
1259
|
+
).parse(
|
|
1260
|
+
this.#db.prepare(
|
|
1261
|
+
`SELECT r.id, o.host, o.pid,
|
|
1262
|
+
COALESCE((SELECT MAX(a.ts) FROM actions a WHERE a.run_id = r.id), r.started_at) AS last
|
|
1263
|
+
FROM runs r LEFT JOIN run_owners o ON o.run_id = r.id
|
|
1264
|
+
WHERE r.status = 'active'`
|
|
1265
|
+
).all()
|
|
1266
|
+
);
|
|
1267
|
+
const abandoned = open.filter(
|
|
1268
|
+
(run) => run.pid === null ? run.last < idleBefore : (
|
|
1269
|
+
// Only a process on this machine can be asked about; one opened
|
|
1270
|
+
// elsewhere on a shared journal is left alone.
|
|
1271
|
+
run.host === here && !running(run.pid)
|
|
1272
|
+
)
|
|
1273
|
+
).map((run) => run.id);
|
|
1274
|
+
const closing = new Set(abandoned);
|
|
1275
|
+
const finished = z.array(
|
|
1276
|
+
z.object({
|
|
1277
|
+
id: z.string(),
|
|
1278
|
+
label: z.string().nullable(),
|
|
1279
|
+
status: z.enum(["active", "complete", "rolled_back", "partial"]),
|
|
1280
|
+
at: z.string(),
|
|
1281
|
+
actions: z.number()
|
|
1282
|
+
})
|
|
1283
|
+
).parse(
|
|
1284
|
+
this.#db.prepare(
|
|
1285
|
+
`SELECT r.id, r.label, r.status, COALESCE(r.ended_at, r.started_at) AS at,
|
|
1286
|
+
(SELECT COUNT(*) FROM actions a WHERE a.run_id = r.id) AS actions
|
|
1287
|
+
FROM runs r
|
|
1288
|
+
WHERE NOT EXISTS (
|
|
1289
|
+
SELECT 1 FROM actions a
|
|
1290
|
+
WHERE a.run_id = r.id
|
|
1291
|
+
AND a.status IN ('pending','gated','approved','rolling_back','unrecoverable'))
|
|
1292
|
+
AND NOT EXISTS (
|
|
1293
|
+
SELECT 1 FROM actions a
|
|
1294
|
+
WHERE a.run_id = r.id AND a.status = 'applied' AND a.inverse_json IS NOT NULL)
|
|
1295
|
+
ORDER BY at, r.rowid`
|
|
1296
|
+
).all()
|
|
1297
|
+
).filter((run) => run.status !== "active" || closing.has(run.id)).map((run) => ({
|
|
1298
|
+
id: run.id,
|
|
1299
|
+
label: run.label ?? void 0,
|
|
1300
|
+
at: run.at,
|
|
1301
|
+
status: run.status === "active" ? "complete" : run.status,
|
|
1302
|
+
actions: run.actions
|
|
1303
|
+
}));
|
|
1304
|
+
return { abandoned, finished };
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1234
1307
|
prunableRuns(before) {
|
|
1235
1308
|
return this.#run(
|
|
1236
1309
|
"prunableRuns",
|
|
@@ -1286,7 +1359,9 @@ var SqliteJournal = class {
|
|
|
1286
1359
|
const dropDenials = this.#db.prepare(
|
|
1287
1360
|
"DELETE FROM denials WHERE action_id IN (SELECT id FROM actions WHERE run_id = ?)"
|
|
1288
1361
|
);
|
|
1362
|
+
const dropOwner = this.#db.prepare("DELETE FROM run_owners WHERE run_id = ?");
|
|
1289
1363
|
for (const id of ids) {
|
|
1364
|
+
dropOwner.run(id);
|
|
1290
1365
|
dropServers.run(id);
|
|
1291
1366
|
dropLeases.run(id);
|
|
1292
1367
|
dropDenials.run(id);
|
|
@@ -1530,7 +1605,7 @@ function walk(current, parts, at, reference) {
|
|
|
1530
1605
|
return walk(current[segment.index], parts, at + 1, reference);
|
|
1531
1606
|
}
|
|
1532
1607
|
case "key": {
|
|
1533
|
-
if (typeof current !== "object" || !(segment.key
|
|
1608
|
+
if (typeof current !== "object" || !Object.hasOwn(current, segment.key)) {
|
|
1534
1609
|
throw new ManifestError(`${reference} is unresolvable: ${segment.key} is absent`);
|
|
1535
1610
|
}
|
|
1536
1611
|
const next = Object.getOwnPropertyDescriptor(current, segment.key)?.value;
|
|
@@ -2072,6 +2147,11 @@ function explainPins(server, faults) {
|
|
|
2072
2147
|
|
|
2073
2148
|
// src/manifest/verify.ts
|
|
2074
2149
|
import { z as z3 } from "zod";
|
|
2150
|
+
async function listAll(upstreams) {
|
|
2151
|
+
return new Map(
|
|
2152
|
+
await Promise.all(upstreams.map(async (upstream) => [upstream.name, await toolShapes(upstream)]))
|
|
2153
|
+
);
|
|
2154
|
+
}
|
|
2075
2155
|
var listSchema = z3.looseObject({
|
|
2076
2156
|
tools: z3.array(
|
|
2077
2157
|
z3.looseObject({
|
|
@@ -2103,11 +2183,11 @@ async function toolShapes(upstream) {
|
|
|
2103
2183
|
} while (cursor !== void 0);
|
|
2104
2184
|
return shapes;
|
|
2105
2185
|
}
|
|
2106
|
-
async function verifyAgainstServers(upstreams, manifest) {
|
|
2186
|
+
async function verifyAgainstServers(upstreams, manifest, listed) {
|
|
2107
2187
|
const shapes = /* @__PURE__ */ new Map();
|
|
2108
2188
|
const available = /* @__PURE__ */ new Map();
|
|
2109
2189
|
for (const upstream of upstreams) {
|
|
2110
|
-
const advertised = await toolShapes(upstream);
|
|
2190
|
+
const advertised = listed?.get(upstream.name) ?? await toolShapes(upstream);
|
|
2111
2191
|
shapes.set(upstream.name, advertised);
|
|
2112
2192
|
available.set(upstream.name, new Set(advertised.map((tool) => tool.name)));
|
|
2113
2193
|
}
|
|
@@ -2154,10 +2234,11 @@ async function verifyAgainstServers(upstreams, manifest) {
|
|
|
2154
2234
|
);
|
|
2155
2235
|
}
|
|
2156
2236
|
}
|
|
2157
|
-
async function withoutMissingTools(upstreams, manifest) {
|
|
2237
|
+
async function withoutMissingTools(upstreams, manifest, listed) {
|
|
2158
2238
|
const available = /* @__PURE__ */ new Map();
|
|
2159
2239
|
for (const upstream of upstreams) {
|
|
2160
|
-
|
|
2240
|
+
const advertised = listed?.get(upstream.name) ?? await toolShapes(upstream);
|
|
2241
|
+
available.set(upstream.name, new Set(advertised.map((tool) => tool.name)));
|
|
2161
2242
|
}
|
|
2162
2243
|
const missing = (qualified) => {
|
|
2163
2244
|
const target = splitQualified(qualified);
|
|
@@ -2537,6 +2618,31 @@ function refusal(error) {
|
|
|
2537
2618
|
}
|
|
2538
2619
|
return void 0;
|
|
2539
2620
|
}
|
|
2621
|
+
async function startTogether(items, start2) {
|
|
2622
|
+
const settled2 = await Promise.allSettled(
|
|
2623
|
+
items.map(async (item) => ({ item, upstream: await start2(item) }))
|
|
2624
|
+
);
|
|
2625
|
+
const started = [];
|
|
2626
|
+
const failed = [];
|
|
2627
|
+
items.forEach((item, index) => {
|
|
2628
|
+
const result = settled2[index];
|
|
2629
|
+
if (result?.status === "fulfilled") {
|
|
2630
|
+
started.push(result.value.upstream);
|
|
2631
|
+
} else if (result !== void 0) {
|
|
2632
|
+
failed.push({ item, error: result.reason });
|
|
2633
|
+
}
|
|
2634
|
+
});
|
|
2635
|
+
return { started, failed };
|
|
2636
|
+
}
|
|
2637
|
+
async function startAll(items, start2) {
|
|
2638
|
+
const { started, failed } = await startTogether(items, start2);
|
|
2639
|
+
const first = failed[0];
|
|
2640
|
+
if (first !== void 0) {
|
|
2641
|
+
await Promise.all(started.map((upstream) => upstream.close().catch(() => void 0)));
|
|
2642
|
+
throw first.error;
|
|
2643
|
+
}
|
|
2644
|
+
return started;
|
|
2645
|
+
}
|
|
2540
2646
|
|
|
2541
2647
|
// src/install/clients.ts
|
|
2542
2648
|
import { existsSync as existsSync3, readFileSync as readFileSync2, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from "fs";
|
|
@@ -2854,7 +2960,15 @@ function discover(cwd) {
|
|
|
2854
2960
|
sites.push({ client, label: LABELS[client], format: "json", path, scope, at: ["mcpServers"] });
|
|
2855
2961
|
}
|
|
2856
2962
|
}
|
|
2857
|
-
|
|
2963
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2964
|
+
return sites.filter((site) => {
|
|
2965
|
+
const key = `${resolve2(site.path)}\0${site.at.join("\0")}`;
|
|
2966
|
+
if (seen.has(key)) {
|
|
2967
|
+
return false;
|
|
2968
|
+
}
|
|
2969
|
+
seen.add(key);
|
|
2970
|
+
return true;
|
|
2971
|
+
});
|
|
2858
2972
|
}
|
|
2859
2973
|
function isRecord(value) {
|
|
2860
2974
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -3870,6 +3984,8 @@ export {
|
|
|
3870
3984
|
cliCommand,
|
|
3871
3985
|
cliCommandFrom,
|
|
3872
3986
|
proxyCommand,
|
|
3987
|
+
JOURNAL_NAME,
|
|
3988
|
+
home,
|
|
3873
3989
|
findManifest,
|
|
3874
3990
|
findJournal,
|
|
3875
3991
|
counted,
|
|
@@ -3891,6 +4007,7 @@ export {
|
|
|
3891
4007
|
splitQualified,
|
|
3892
4008
|
fingerprint,
|
|
3893
4009
|
pinBlock,
|
|
4010
|
+
listAll,
|
|
3894
4011
|
toolShapes,
|
|
3895
4012
|
verifyAgainstServers,
|
|
3896
4013
|
withoutMissingTools,
|
|
@@ -3918,6 +4035,8 @@ export {
|
|
|
3918
4035
|
differing,
|
|
3919
4036
|
declaredNames,
|
|
3920
4037
|
connectUpstream,
|
|
4038
|
+
startTogether,
|
|
4039
|
+
startAll,
|
|
3921
4040
|
LOOKED_FOR,
|
|
3922
4041
|
CLIENT_IDS,
|
|
3923
4042
|
isClientId,
|
|
@@ -3932,4 +4051,4 @@ export {
|
|
|
3932
4051
|
applyUninstall,
|
|
3933
4052
|
clientEnvFor
|
|
3934
4053
|
};
|
|
3935
|
-
//# sourceMappingURL=chunk-
|
|
4054
|
+
//# sourceMappingURL=chunk-MTPUG26S.js.map
|
package/dist/cli.js
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
isClientId,
|
|
34
34
|
isWrapped,
|
|
35
35
|
labelFor,
|
|
36
|
+
listAll,
|
|
36
37
|
loadManifest,
|
|
37
38
|
observeState,
|
|
38
39
|
openJournal,
|
|
@@ -48,6 +49,8 @@ import {
|
|
|
48
49
|
serversAt,
|
|
49
50
|
splitQualified,
|
|
50
51
|
standing,
|
|
52
|
+
startAll,
|
|
53
|
+
startTogether,
|
|
51
54
|
style,
|
|
52
55
|
toPayload,
|
|
53
56
|
toResolvedRead,
|
|
@@ -59,7 +62,7 @@ import {
|
|
|
59
62
|
verifyAgainstServers,
|
|
60
63
|
warnUntested,
|
|
61
64
|
wasRefused
|
|
62
|
-
} from "./chunk-
|
|
65
|
+
} from "./chunk-MTPUG26S.js";
|
|
63
66
|
import {
|
|
64
67
|
DriftConflict,
|
|
65
68
|
ManifestError,
|
|
@@ -372,7 +375,11 @@ ${seen}` : seen;
|
|
|
372
375
|
}
|
|
373
376
|
if (sameState(current, recordedPost.data)) {
|
|
374
377
|
verified = true;
|
|
375
|
-
} else if (sameState(current, intendedAfterInverse(action))
|
|
378
|
+
} else if (sameState(current, intendedAfterInverse(action)) || // An inverse this undo sent before it was killed, whose owner is gone:
|
|
379
|
+
// the resource having reached the state it produces is the inverse
|
|
380
|
+
// having landed. Found by SIGKILLing undo mid-compensation: the delete
|
|
381
|
+
// had gone through, and every later attempt called its absence drift.
|
|
382
|
+
action.status === "rolling_back" && journal.leaseFor(action.id)?.alive === false && looksUndone(current, action)) {
|
|
376
383
|
steps.push({
|
|
377
384
|
...describeStep(action),
|
|
378
385
|
kind: "already-reverted",
|
|
@@ -487,6 +494,19 @@ ${seen}` : seen;
|
|
|
487
494
|
journal.markRolledBack(action.id);
|
|
488
495
|
continue;
|
|
489
496
|
}
|
|
497
|
+
const landed = outcome.rejected && recordedPost.success && verifyRead.success ? await landedAnyway(router, toResolvedRead(verifyRead.data), recordedPost.data, action, signal) : void 0;
|
|
498
|
+
if (landed !== void 0) {
|
|
499
|
+
journal.markRolledBack(action.id);
|
|
500
|
+
steps[steps.length - 1] = {
|
|
501
|
+
...describeStep(action),
|
|
502
|
+
kind: "revert",
|
|
503
|
+
reason: landed,
|
|
504
|
+
verified,
|
|
505
|
+
plan,
|
|
506
|
+
note: `the server reported an error (${truncated(outcome.message)}), but the resource had changed as this inverse changes it`
|
|
507
|
+
};
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
490
510
|
const halt = new RollbackHalted(action.seq, outcome.message);
|
|
491
511
|
if (outcome.rejected) {
|
|
492
512
|
journal.markInverseRejected(action.id, halt.message);
|
|
@@ -544,6 +564,28 @@ function overwriteText(current, action) {
|
|
|
544
564
|
}
|
|
545
565
|
return changedLines(current, intended2);
|
|
546
566
|
}
|
|
567
|
+
async function landedAnyway(router, read, post, action, signal) {
|
|
568
|
+
let now;
|
|
569
|
+
try {
|
|
570
|
+
now = await observeState(router, read, signal);
|
|
571
|
+
} catch {
|
|
572
|
+
return void 0;
|
|
573
|
+
}
|
|
574
|
+
if (sameState(now, post)) {
|
|
575
|
+
return void 0;
|
|
576
|
+
}
|
|
577
|
+
return looksUndone(now, action) ? "done, despite the server reporting an error" : void 0;
|
|
578
|
+
}
|
|
579
|
+
function looksUndone(now, action) {
|
|
580
|
+
const intended2 = intendedAfterInverse(action);
|
|
581
|
+
if (intended2 !== void 0) {
|
|
582
|
+
return sameState(now, intended2);
|
|
583
|
+
}
|
|
584
|
+
return action.class === "compensable" && !now.present;
|
|
585
|
+
}
|
|
586
|
+
function truncated(text) {
|
|
587
|
+
return text.length > 160 ? `${text.slice(0, 157)}...` : text;
|
|
588
|
+
}
|
|
547
589
|
function intendedAfterInverse(action) {
|
|
548
590
|
return action.snapshot === void 0 ? void 0 : { present: true, value: action.snapshot };
|
|
549
591
|
}
|
|
@@ -1084,7 +1126,12 @@ function render(journal, options, tick, view) {
|
|
|
1084
1126
|
});
|
|
1085
1127
|
out2.push("");
|
|
1086
1128
|
out2.push(
|
|
1087
|
-
canDecide(options) ? ` ${keyHint("a", "approve")} ${keyHint("A", "
|
|
1129
|
+
canDecide(options) ? ` ${keyHint("a", "approve")} ${keyHint("A", "approve for an hour")} ${keyHint("d", "deny")} ${keyHint("j/k", "move")} ${keyHint("q", "quit")}` : ` ${style.quiet(`${options.approveWith} approve --all`)}`
|
|
1130
|
+
);
|
|
1131
|
+
} else if (canDecide(options)) {
|
|
1132
|
+
out2.push("");
|
|
1133
|
+
out2.push(
|
|
1134
|
+
` ${keyHint("q", "quit")} ${style.quiet("A held call appears here; answer it with")} ${style.strong("a")} ${style.quiet("approve,")} ${style.strong("A")} ${style.quiet("approve for an hour, or")} ${style.strong("d")} ${style.quiet("deny.")}`
|
|
1088
1135
|
);
|
|
1089
1136
|
}
|
|
1090
1137
|
if (view.notice !== "") {
|
|
@@ -1151,6 +1198,8 @@ async function watch(options) {
|
|
|
1151
1198
|
const waiting = ready.listGated();
|
|
1152
1199
|
const action = waiting[Math.min(view.cursor, waiting.length - 1)];
|
|
1153
1200
|
if (action === void 0) {
|
|
1201
|
+
view.notice = "Nothing is waiting right now. When an agent's call is held, it appears above and this key answers it.";
|
|
1202
|
+
view.noticeUntil = tick + NOTICE_TICKS;
|
|
1154
1203
|
return;
|
|
1155
1204
|
}
|
|
1156
1205
|
const changed = approve ? ready.approve(action.id, options.decideAs) : ready.denyByPerson(action.id, options.decideAs, "denied from the watch view");
|
|
@@ -1185,6 +1234,10 @@ async function watch(options) {
|
|
|
1185
1234
|
view.cursor = Math.max(0, view.cursor - 1);
|
|
1186
1235
|
return;
|
|
1187
1236
|
default:
|
|
1237
|
+
if (key.length === 1 && key >= " ") {
|
|
1238
|
+
view.notice = `${key} does nothing here. Keys: a approve, A approve for an hour, d deny, j/k move, q quit`;
|
|
1239
|
+
view.noticeUntil = tick + NOTICE_TICKS;
|
|
1240
|
+
}
|
|
1188
1241
|
return;
|
|
1189
1242
|
}
|
|
1190
1243
|
};
|
|
@@ -1278,7 +1331,9 @@ function scan(journal, cwd) {
|
|
|
1278
1331
|
}
|
|
1279
1332
|
const connections = Object.entries(servers).map(([server, entry]) => {
|
|
1280
1333
|
const covered = isWrapped(entry);
|
|
1281
|
-
const
|
|
1334
|
+
const args = entry.args ?? [];
|
|
1335
|
+
const named = covered && args.includes("--server") ? args[args.indexOf("--server") + 1] : void 0;
|
|
1336
|
+
const lastSeen = seen.get(named ?? server);
|
|
1282
1337
|
return {
|
|
1283
1338
|
client: site.client,
|
|
1284
1339
|
scope: site.scope,
|
|
@@ -1526,12 +1581,13 @@ function gatesView(journal, screen, options) {
|
|
|
1526
1581
|
return [` ${style.quiet("Nothing is waiting for a decision.")}`];
|
|
1527
1582
|
}
|
|
1528
1583
|
const at = Math.min(screen.cursor, waiting.length - 1);
|
|
1529
|
-
return waiting.
|
|
1584
|
+
return waiting.flatMap((action, index) => {
|
|
1530
1585
|
const here = index === at && canPress(options);
|
|
1531
1586
|
const name = `${action.server}.${action.tool}`;
|
|
1532
1587
|
const shown = here ? style.accent(name) : style.quiet(name);
|
|
1533
1588
|
const args = style.quiet(truncate2(JSON.stringify(action.args), 54));
|
|
1534
|
-
|
|
1589
|
+
const why = here && action.error !== void 0 ? [` ${style.quiet(truncate2(action.error, 90))}`] : [];
|
|
1590
|
+
return [` ${here ? style.accent(CURSOR) : " "} ${shown} ${args}`, ...why];
|
|
1535
1591
|
});
|
|
1536
1592
|
}
|
|
1537
1593
|
function footer(screen, options) {
|
|
@@ -1542,7 +1598,7 @@ function footer(screen, options) {
|
|
|
1542
1598
|
const what = screen.confirmingForce ? `undo ${screen.confirmingLabel ?? "this session"} anyway, losing that change?` : `undo ${screen.confirmingLabel ?? "this session"}?`;
|
|
1543
1599
|
return ["", ` ${style.accent(what)} ${keyHint2("y", "yes")} ${keyHint2("n", "no")}`];
|
|
1544
1600
|
}
|
|
1545
|
-
const keys = screen.mode === "connections" ? [keyHint2("enter", "connect"), keyHint2("a", "connect all"), keyHint2("r", "rescan"), keyHint2("j/k", "move"), keyHint2("h", "back")] : screen.mode === "gates" ? [keyHint2("a", "approve"), keyHint2("A", "
|
|
1601
|
+
const keys = screen.mode === "connections" ? [keyHint2("enter", "connect"), keyHint2("a", "connect all"), keyHint2("r", "rescan"), keyHint2("j/k", "move"), keyHint2("h", "back")] : screen.mode === "gates" ? [keyHint2("a", "approve"), keyHint2("A", "approve for an hour"), keyHint2("d", "deny"), keyHint2("j/k", "move"), keyHint2("r", "runs")] : screen.mode === "run" ? [
|
|
1546
1602
|
keyHint2("l", "check now"),
|
|
1547
1603
|
keyHint2("p", "preview undo"),
|
|
1548
1604
|
keyHint2("u", "undo"),
|
|
@@ -1553,10 +1609,38 @@ function footer(screen, options) {
|
|
|
1553
1609
|
keyHint2("l", "check now"),
|
|
1554
1610
|
keyHint2("u", "undo"),
|
|
1555
1611
|
keyHint2("j/k", "move"),
|
|
1556
|
-
keyHint2("g", "
|
|
1612
|
+
keyHint2("g", "waiting calls")
|
|
1557
1613
|
];
|
|
1558
1614
|
return ["", ` ${keys.join(" ")} ${keyHint2("q", "quit")}`];
|
|
1559
1615
|
}
|
|
1616
|
+
function welcomeFrame(screen, options, tick) {
|
|
1617
|
+
const spinner = options.live ? `${style.accent(FRAMES2[tick % FRAMES2.length] ?? "")} ` : "";
|
|
1618
|
+
const waiting = needsConnecting(screen.groups).length;
|
|
1619
|
+
const lead = screen.groups.length === 0 ? [] : waiting === 0 ? [
|
|
1620
|
+
` ${style.strong("Everything below is covered.")} ${style.quiet("Restart those apps if you have not,")}`,
|
|
1621
|
+
` ${style.quiet("then use your agent as usual. Its sessions will appear here.")}`
|
|
1622
|
+
] : [
|
|
1623
|
+
` ${style.strong("Welcome.")} ${style.quiet("These are the AI apps on this machine and the tools they use.")}`,
|
|
1624
|
+
` ${style.quiet("Press")} ${style.strong("a")} ${style.quiet("to cover all of them, or move to one and press")} ${style.strong("enter")}${style.quiet(".")}`,
|
|
1625
|
+
` ${style.quiet("Nothing else on your machine is changed, and")} ${style.strong("synartesis uninstall")} ${style.quiet("puts it all back.")}`
|
|
1626
|
+
];
|
|
1627
|
+
const notice = screen.notice === "" ? [] : ["", ...screen.notice.split("\n").map((line2) => ` ${style.accent(line2)}`)];
|
|
1628
|
+
return [
|
|
1629
|
+
"",
|
|
1630
|
+
` ${style.plate(WORDMARK)}`,
|
|
1631
|
+
` ${rule(70)}`,
|
|
1632
|
+
"",
|
|
1633
|
+
` ${spinner}${style.quiet(screen.busy ?? "nothing has run through Synartesis yet")}`,
|
|
1634
|
+
"",
|
|
1635
|
+
...lead,
|
|
1636
|
+
...lead.length === 0 ? [] : [""],
|
|
1637
|
+
...windowed(connectionsView(screen, options), screen.cursor, roomFor(options)),
|
|
1638
|
+
...notice,
|
|
1639
|
+
"",
|
|
1640
|
+
` ${[keyHint2("a", "cover all"), keyHint2("enter", "cover this one"), keyHint2("j/k", "move"), keyHint2("r", "look again"), keyHint2("q", "quit")].join(" ")}`,
|
|
1641
|
+
""
|
|
1642
|
+
].join("\n");
|
|
1643
|
+
}
|
|
1560
1644
|
function waitingForJournal2(options, tick) {
|
|
1561
1645
|
const spinner = options.live ? `${style.accent(FRAMES2[tick % FRAMES2.length] ?? "")} ` : "";
|
|
1562
1646
|
return [
|
|
@@ -1611,7 +1695,8 @@ async function openConsole(options) {
|
|
|
1611
1695
|
groups: [],
|
|
1612
1696
|
busy: void 0,
|
|
1613
1697
|
notice: "",
|
|
1614
|
-
noticeUntil: 0
|
|
1698
|
+
noticeUntil: 0,
|
|
1699
|
+
welcome: false
|
|
1615
1700
|
};
|
|
1616
1701
|
let tick = 0;
|
|
1617
1702
|
const stopped = () => screen.stop;
|
|
@@ -1622,15 +1707,30 @@ async function openConsole(options) {
|
|
|
1622
1707
|
const frame = () => {
|
|
1623
1708
|
const ready = open();
|
|
1624
1709
|
if (ready === void 0) {
|
|
1710
|
+
if (options.scan !== void 0 && canPress(options)) {
|
|
1711
|
+
return welcomeFrame(screen, options, tick);
|
|
1712
|
+
}
|
|
1625
1713
|
return waitingForJournal2(options, tick);
|
|
1626
1714
|
}
|
|
1715
|
+
if (screen.welcome) {
|
|
1716
|
+
screen.welcome = false;
|
|
1717
|
+
screen.mode = "runs";
|
|
1718
|
+
screen.cursor = 0;
|
|
1719
|
+
say("the first session has arrived");
|
|
1720
|
+
}
|
|
1627
1721
|
const tail = command(ready);
|
|
1628
1722
|
const shout = screen.notice === "" ? 0 : screen.notice.split("\n").length;
|
|
1629
1723
|
const room = Math.max(3, roomFor(options) - tail.length - shout);
|
|
1630
1724
|
const body = screen.mode === "runs" ? windowed(runsView(ready, screen, options), screen.cursor, room) : screen.mode === "run" ? windowed(runView(ready, screen), 0, room) : screen.mode === "connections" ? windowed(connectionsView(screen, options), screen.cursor, room) : windowed(gatesView(ready, screen, options), screen.cursor, room);
|
|
1631
1725
|
const notice = screen.notice === "" ? [] : ["", ...screen.notice.split("\n").map((line2) => ` ${style.accent(line2)}`)];
|
|
1726
|
+
const held2 = screen.mode === "gates" || screen.mode === "connections" ? 0 : ready.listGated().length;
|
|
1727
|
+
const alert = held2 === 0 ? [] : [
|
|
1728
|
+
` ${style.accent(`${String(held2)} ${held2 === 1 ? "call is" : "calls are"} waiting for you`)} ${style.quiet("press")} ${style.strong("g")} ${style.quiet("to answer")}`,
|
|
1729
|
+
""
|
|
1730
|
+
];
|
|
1632
1731
|
return [
|
|
1633
1732
|
...header(options, screen, tick),
|
|
1733
|
+
...alert,
|
|
1634
1734
|
...body,
|
|
1635
1735
|
...notice,
|
|
1636
1736
|
...tail,
|
|
@@ -1754,6 +1854,13 @@ async function openConsole(options) {
|
|
|
1754
1854
|
const rescan = () => {
|
|
1755
1855
|
screen.groups = options.scan?.() ?? [];
|
|
1756
1856
|
};
|
|
1857
|
+
if (!existsSync3(options.journalPath) && options.scan !== void 0) {
|
|
1858
|
+
screen.welcome = true;
|
|
1859
|
+
screen.mode = "connections";
|
|
1860
|
+
rescan();
|
|
1861
|
+
} else if (canPress(options) && (open()?.listGated().length ?? 0) > 0) {
|
|
1862
|
+
screen.mode = "gates";
|
|
1863
|
+
}
|
|
1757
1864
|
const connect = async (targets) => {
|
|
1758
1865
|
if (options.connect === void 0) {
|
|
1759
1866
|
say("no way to connect was configured");
|
|
@@ -1937,6 +2044,9 @@ async function openConsole(options) {
|
|
|
1937
2044
|
return;
|
|
1938
2045
|
}
|
|
1939
2046
|
default:
|
|
2047
|
+
if (key.length === 1 && key >= " ") {
|
|
2048
|
+
say(`${key} does nothing here; the keys that work are listed on the bottom line`);
|
|
2049
|
+
}
|
|
1940
2050
|
return;
|
|
1941
2051
|
}
|
|
1942
2052
|
};
|
|
@@ -1954,6 +2064,9 @@ async function openConsole(options) {
|
|
|
1954
2064
|
return;
|
|
1955
2065
|
}
|
|
1956
2066
|
press(next.value);
|
|
2067
|
+
if (screen.welcome && open() === void 0) {
|
|
2068
|
+
screen.mode = "connections";
|
|
2069
|
+
}
|
|
1957
2070
|
if (stopped()) {
|
|
1958
2071
|
return;
|
|
1959
2072
|
}
|
|
@@ -2064,11 +2177,12 @@ if (NODE_MAJOR < 22) {
|
|
|
2064
2177
|
process.exit(2);
|
|
2065
2178
|
}
|
|
2066
2179
|
var COMMANDS = `
|
|
2067
|
-
synartesis start here.
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2180
|
+
synartesis start here. The first time,
|
|
2181
|
+
it lists your AI apps: press a
|
|
2182
|
+
to cover them. After that it
|
|
2183
|
+
opens on anything waiting for
|
|
2184
|
+
you, and on what agents did,
|
|
2185
|
+
with undo on the arrow keys.
|
|
2072
2186
|
synartesis install [--client <name>] [--remote] [--dry-run] [--print]
|
|
2073
2187
|
synartesis uninstall [--client <name>]
|
|
2074
2188
|
synartesis status
|
|
@@ -2079,6 +2193,8 @@ var COMMANDS = `
|
|
|
2079
2193
|
synartesis show <runId> [--full] [--live] [--journal <path>]
|
|
2080
2194
|
synartesis gates [--journal <path>]
|
|
2081
2195
|
synartesis close [runId] [--journal <path>]
|
|
2196
|
+
synartesis clean [--dry-run] [--yes] clear finished sessions,
|
|
2197
|
+
keep what still matters
|
|
2082
2198
|
synartesis prune [--older-than <days>] [--dry-run] [--journal <path>]
|
|
2083
2199
|
synartesis proxy --manifest <path> [--server <name>] what your agent runs
|
|
2084
2200
|
[--journal <path>]
|
|
@@ -2254,14 +2370,16 @@ async function startAsTheClientWould(manifestPath, name, spec, session) {
|
|
|
2254
2370
|
async function runPin(argv) {
|
|
2255
2371
|
const path = findManifest(flag(argv, "--manifest"));
|
|
2256
2372
|
const manifest = loadManifest(path);
|
|
2257
|
-
|
|
2373
|
+
let shapes = /* @__PURE__ */ new Map();
|
|
2258
2374
|
const upstreams = [];
|
|
2259
2375
|
try {
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2376
|
+
upstreams.push(
|
|
2377
|
+
...await startAll(
|
|
2378
|
+
Object.entries(manifest.servers),
|
|
2379
|
+
([name, spec]) => startAsTheClientWould(path, name, spec)
|
|
2380
|
+
)
|
|
2381
|
+
);
|
|
2382
|
+
shapes = await listAll(upstreams);
|
|
2265
2383
|
} finally {
|
|
2266
2384
|
for (const upstream of upstreams) {
|
|
2267
2385
|
await upstream.close();
|
|
@@ -2321,13 +2439,17 @@ async function runCheck(argv) {
|
|
|
2321
2439
|
const offered = /* @__PURE__ */ new Map();
|
|
2322
2440
|
const trustedReads = /* @__PURE__ */ new Map();
|
|
2323
2441
|
try {
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2442
|
+
upstreams.push(
|
|
2443
|
+
...await startAll(
|
|
2444
|
+
Object.entries(manifest.servers),
|
|
2445
|
+
([name, spec]) => startAsTheClientWould(path, name, spec)
|
|
2446
|
+
)
|
|
2447
|
+
);
|
|
2448
|
+
const listed = await listAll(upstreams);
|
|
2449
|
+
await verifyAgainstServers(upstreams, manifest, listed);
|
|
2328
2450
|
const resolver = createPolicyResolver(manifest);
|
|
2329
2451
|
for (const upstream of upstreams) {
|
|
2330
|
-
const shapes =
|
|
2452
|
+
const shapes = listed.get(upstream.name) ?? [];
|
|
2331
2453
|
const trusted = shapes.filter(
|
|
2332
2454
|
(tool) => tool.readOnly === true && trustsMarks(manifest, upstream.name) && !resolver.resolve(`${upstream.name}.${tool.name}`).matched
|
|
2333
2455
|
).map((tool) => tool.name);
|
|
@@ -2594,10 +2716,16 @@ async function connectThese(targets, manifestPath) {
|
|
|
2594
2716
|
);
|
|
2595
2717
|
const applied = applyInstall(plans, manifestPath, yaml);
|
|
2596
2718
|
const count = applied.reduce((sum, entry) => sum + entry.servers.length, 0);
|
|
2719
|
+
const skipped = plans.flatMap((plan) => plan.skipped.map((skip) => `${skip.name}: ${skip.why}`));
|
|
2597
2720
|
if (count === 0) {
|
|
2598
|
-
return "nothing
|
|
2721
|
+
return skipped.length === 0 ? "nothing needed connecting" : `not connected -- ${skipped.join("; ")}`;
|
|
2599
2722
|
}
|
|
2600
|
-
|
|
2723
|
+
const apps = [...new Set(applied.map((entry) => entry.site.label))];
|
|
2724
|
+
const restart = `quit and reopen ${apps.join(" and ")} to start using it`;
|
|
2725
|
+
return [
|
|
2726
|
+
`covered ${String(count)}${count === 1 ? " server" : " servers"} \xB7 ${restart}`,
|
|
2727
|
+
...skipped.length === 0 ? [] : [`not covered -- ${skipped.join("; ")}`]
|
|
2728
|
+
].join("\n");
|
|
2601
2729
|
}
|
|
2602
2730
|
function runStatus(argv) {
|
|
2603
2731
|
const manifestPath = findManifest(flag(argv, "--manifest"));
|
|
@@ -2884,10 +3012,15 @@ function runList(journal, asJson, journalPath) {
|
|
|
2884
3012
|
}
|
|
2885
3013
|
hint(firstOf(
|
|
2886
3014
|
() => heldCalls(journal),
|
|
3015
|
+
() => {
|
|
3016
|
+
const clearable = journal.cleanable(new Date(Date.now() - CLEAN_IDLE_MS).toISOString()).finished.length;
|
|
3017
|
+
return clearable >= CLEAN_NAG_SESSIONS ? { why: `${String(clearable)} of these are finished with nothing left to undo; to clear them`, run: "clean", needs: ["journal"] } : void 0;
|
|
3018
|
+
},
|
|
2887
3019
|
() => whatChanged(journal)
|
|
2888
3020
|
));
|
|
2889
3021
|
return 0;
|
|
2890
3022
|
}
|
|
3023
|
+
var CLEAN_NAG_SESSIONS = 10;
|
|
2891
3024
|
async function runShow(argv, journal, asJson) {
|
|
2892
3025
|
const full = argv.includes("--full");
|
|
2893
3026
|
const runs = [...journal.listRuns()].reverse();
|
|
@@ -3177,6 +3310,66 @@ function runPrune(argv, journal, journalPath) {
|
|
|
3177
3310
|
out("");
|
|
3178
3311
|
return 0;
|
|
3179
3312
|
}
|
|
3313
|
+
var CLEAN_IDLE_MS = 24 * 60 * 60 * 1e3;
|
|
3314
|
+
async function runClean(argv, journal, journalPath) {
|
|
3315
|
+
const idleBefore = new Date(Date.now() - CLEAN_IDLE_MS).toISOString();
|
|
3316
|
+
const { abandoned, finished } = journal.cleanable(idleBefore);
|
|
3317
|
+
const total = journal.listRuns().length;
|
|
3318
|
+
const empty = finished.filter((run) => run.actions === 0).length;
|
|
3319
|
+
const kept = total - finished.length;
|
|
3320
|
+
const sizeBefore = sizeOf(journalPath);
|
|
3321
|
+
out("");
|
|
3322
|
+
out(` ${style.label("clean")} ${style.quiet(journalPath)}`);
|
|
3323
|
+
out(` ${rule(54)}`);
|
|
3324
|
+
out("");
|
|
3325
|
+
if (finished.length === 0 && abandoned.length === 0) {
|
|
3326
|
+
out(` ${style.quiet("Nothing to clear: every session here is still in use, or still has")}`);
|
|
3327
|
+
out(` ${style.quiet("something you could undo or decide.")}`);
|
|
3328
|
+
out("");
|
|
3329
|
+
return 0;
|
|
3330
|
+
}
|
|
3331
|
+
out(
|
|
3332
|
+
` ${style.strong("clears")} ${counted(finished.length, "finished session")} with nothing left to undo` + (empty > 0 ? style.quiet(` (${String(empty)} of them empty)`) : "")
|
|
3333
|
+
);
|
|
3334
|
+
if (abandoned.length > 0) {
|
|
3335
|
+
out(` ${style.strong("closes")} ${counted(abandoned.length, "session")} left open by an app that is no longer running`);
|
|
3336
|
+
}
|
|
3337
|
+
out(
|
|
3338
|
+
` ${style.strong("keeps ")} ${counted(kept, "session")} ${style.quiet("still running, or with something to undo or decide")}`
|
|
3339
|
+
);
|
|
3340
|
+
out(` ${style.quiet(" your connected apps and your policy are not touched")}`);
|
|
3341
|
+
out("");
|
|
3342
|
+
if (argv.includes("--dry-run")) {
|
|
3343
|
+
out(` ${style.quiet("Nothing was changed.")}`);
|
|
3344
|
+
out("");
|
|
3345
|
+
return 0;
|
|
3346
|
+
}
|
|
3347
|
+
if (!argv.includes("--yes")) {
|
|
3348
|
+
if (!process.stdin.isTTY) {
|
|
3349
|
+
out(` ${style.quiet("Nothing was changed. Run it in a terminal to be asked, or add --yes.")}`);
|
|
3350
|
+
out("");
|
|
3351
|
+
return 1;
|
|
3352
|
+
}
|
|
3353
|
+
const answer = (await ask(" Go ahead? [y/N] ")).trim().toLowerCase();
|
|
3354
|
+
if (answer !== "y" && answer !== "yes") {
|
|
3355
|
+
out(` ${style.quiet("Nothing was changed.")}`);
|
|
3356
|
+
out("");
|
|
3357
|
+
return 0;
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
for (const id of abandoned) {
|
|
3361
|
+
journal.closeAbandonedRun(id);
|
|
3362
|
+
}
|
|
3363
|
+
const now = journal.cleanable(idleBefore).finished;
|
|
3364
|
+
const removed = journal.deleteRuns(now.map((run) => run.id));
|
|
3365
|
+
journal.vacuum();
|
|
3366
|
+
out("");
|
|
3367
|
+
out(
|
|
3368
|
+
` ${style.accent(`cleared ${counted(removed.runs, "session")}`)}` + style.quiet(`${abandoned.length > 0 ? `, closed ${String(abandoned.length)}` : ""} \xB7 journal ${sizeBefore} \u2192 ${sizeOf(journalPath)}`)
|
|
3369
|
+
);
|
|
3370
|
+
out("");
|
|
3371
|
+
return 0;
|
|
3372
|
+
}
|
|
3180
3373
|
function runResolve(argv, journal) {
|
|
3181
3374
|
const applied = argv.includes("--applied");
|
|
3182
3375
|
const failed = argv.includes("--failed");
|
|
@@ -3663,19 +3856,14 @@ function report(result, alreadyForcing = false, as = "") {
|
|
|
3663
3856
|
}
|
|
3664
3857
|
async function withUpstreams(manifestPath, use, only, session) {
|
|
3665
3858
|
const manifest = loadManifest(manifestPath);
|
|
3666
|
-
const
|
|
3667
|
-
const
|
|
3859
|
+
const wanted = Object.entries(manifest.servers).filter(([name]) => only === void 0 || only.has(name));
|
|
3860
|
+
const { started, failed } = await startTogether(
|
|
3861
|
+
wanted,
|
|
3862
|
+
([name, spec]) => startAsTheClientWould(manifestPath, name, spec, session)
|
|
3863
|
+
);
|
|
3864
|
+
const upstreams = [...started];
|
|
3865
|
+
const missing = failed.map(({ item: [name], error }) => `${name}: ${describe(error)}`);
|
|
3668
3866
|
try {
|
|
3669
|
-
for (const [name, spec] of Object.entries(manifest.servers)) {
|
|
3670
|
-
if (only !== void 0 && !only.has(name)) {
|
|
3671
|
-
continue;
|
|
3672
|
-
}
|
|
3673
|
-
try {
|
|
3674
|
-
upstreams.push(await startAsTheClientWould(manifestPath, name, spec, session));
|
|
3675
|
-
} catch (error) {
|
|
3676
|
-
missing.push(`${name}: ${describe(error)}`);
|
|
3677
|
-
}
|
|
3678
|
-
}
|
|
3679
3867
|
if (upstreams.length === 0 && missing.length > 0) {
|
|
3680
3868
|
throw new ManifestError(`no server could be started. ${missing.join("; ")}`);
|
|
3681
3869
|
}
|
|
@@ -3869,6 +4057,7 @@ var KNOWN_COMMANDS = [
|
|
|
3869
4057
|
"show",
|
|
3870
4058
|
"gates",
|
|
3871
4059
|
"close",
|
|
4060
|
+
"clean",
|
|
3872
4061
|
"prune",
|
|
3873
4062
|
"proxy",
|
|
3874
4063
|
"desktop",
|
|
@@ -4094,6 +4283,8 @@ ${COMMANDS}`);
|
|
|
4094
4283
|
return runClose(argv, journal);
|
|
4095
4284
|
case "prune":
|
|
4096
4285
|
return runPrune(argv, journal, journalPath);
|
|
4286
|
+
case "clean":
|
|
4287
|
+
return await runClean(argv, journal, journalPath);
|
|
4097
4288
|
case "gates":
|
|
4098
4289
|
return runGates(journal, asJson);
|
|
4099
4290
|
case "resolve":
|
package/dist/proxy.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
JOURNAL_NAME,
|
|
3
4
|
PROXY_FLAGS,
|
|
4
5
|
SILENT,
|
|
5
6
|
canonical,
|
|
@@ -13,7 +14,9 @@ import {
|
|
|
13
14
|
findJournal,
|
|
14
15
|
findManifest,
|
|
15
16
|
fingerprint2 as fingerprint,
|
|
17
|
+
home,
|
|
16
18
|
isDisconnected,
|
|
19
|
+
listAll,
|
|
17
20
|
loadManifest,
|
|
18
21
|
mark,
|
|
19
22
|
observeState,
|
|
@@ -23,8 +26,8 @@ import {
|
|
|
23
26
|
qualify,
|
|
24
27
|
refusal,
|
|
25
28
|
runRead,
|
|
29
|
+
startAll,
|
|
26
30
|
toPayload,
|
|
27
|
-
toolShapes,
|
|
28
31
|
trustsMarks,
|
|
29
32
|
ungoverned,
|
|
30
33
|
untested,
|
|
@@ -33,7 +36,7 @@ import {
|
|
|
33
36
|
warnUntested,
|
|
34
37
|
withIdempotencyKey,
|
|
35
38
|
withoutMissingTools
|
|
36
|
-
} from "./chunk-
|
|
39
|
+
} from "./chunk-MTPUG26S.js";
|
|
37
40
|
import {
|
|
38
41
|
SnapshotError,
|
|
39
42
|
UpstreamError,
|
|
@@ -41,7 +44,7 @@ import {
|
|
|
41
44
|
} from "./chunk-YVOO3PTV.js";
|
|
42
45
|
|
|
43
46
|
// src/proxy/stdio.ts
|
|
44
|
-
import { resolve } from "path";
|
|
47
|
+
import { join, resolve } from "path";
|
|
45
48
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
46
49
|
|
|
47
50
|
// src/proxy/http.ts
|
|
@@ -641,7 +644,7 @@ function createProxyServer(options) {
|
|
|
641
644
|
}
|
|
642
645
|
tools.push({
|
|
643
646
|
...compatible(tool, (dialect) => {
|
|
644
|
-
log?.
|
|
647
|
+
log?.debug(
|
|
645
648
|
`${upstream.name}.${tool.name} declares JSON Schema ${dialect}; the dialect was dropped so clients that only accept 2020-12 can call it`
|
|
646
649
|
);
|
|
647
650
|
}),
|
|
@@ -1303,8 +1306,8 @@ async function main() {
|
|
|
1303
1306
|
const upstreams = [];
|
|
1304
1307
|
const key = journal.fingerprintKey();
|
|
1305
1308
|
const startedWith = /* @__PURE__ */ new Map();
|
|
1309
|
+
upstreams.push(...await startAll([...wanted], ([name, spec]) => connectUpstream(name, spec, { env: source })));
|
|
1306
1310
|
for (const [name, spec] of wanted) {
|
|
1307
|
-
upstreams.push(await connectUpstream(name, spec, { env: source }));
|
|
1308
1311
|
let client;
|
|
1309
1312
|
try {
|
|
1310
1313
|
client = source.kind === "inherit" ? clientEnvFor(argv.manifest, name)?.env : void 0;
|
|
@@ -1316,7 +1319,8 @@ async function main() {
|
|
|
1316
1319
|
fingerprints: fingerprint(key, upstreamEnv(name, spec, source), declaredNames(spec, client))
|
|
1317
1320
|
});
|
|
1318
1321
|
}
|
|
1319
|
-
const
|
|
1322
|
+
const listed = await listAll(upstreams);
|
|
1323
|
+
const { manifest: served, disabled } = await withoutMissingTools(upstreams, manifest, listed);
|
|
1320
1324
|
for (const line of disabled) {
|
|
1321
1325
|
log.warn(line);
|
|
1322
1326
|
}
|
|
@@ -1325,14 +1329,15 @@ async function main() {
|
|
|
1325
1329
|
argv.server === void 0 ? served : {
|
|
1326
1330
|
...served,
|
|
1327
1331
|
tools: served.tools.filter((rule) => rule.match.startsWith(`${String(argv.server)}.`))
|
|
1328
|
-
}
|
|
1332
|
+
},
|
|
1333
|
+
listed
|
|
1329
1334
|
);
|
|
1330
1335
|
const uncovered = ungoverned(
|
|
1331
1336
|
served,
|
|
1332
|
-
new Map(
|
|
1337
|
+
new Map(upstreams.map((upstream) => [
|
|
1333
1338
|
upstream.name,
|
|
1334
|
-
(
|
|
1335
|
-
]))
|
|
1339
|
+
(listed.get(upstream.name) ?? []).filter((tool) => !(tool.readOnly === true && trustsMarks(served, upstream.name))).map((tool) => tool.name)
|
|
1340
|
+
]))
|
|
1336
1341
|
);
|
|
1337
1342
|
for (const entry of uncovered) {
|
|
1338
1343
|
log.warn(
|
|
@@ -1359,7 +1364,13 @@ async function main() {
|
|
|
1359
1364
|
logger: log,
|
|
1360
1365
|
// Absolute, because whoever approves may be in any directory at all.
|
|
1361
1366
|
approveHint: (actionId) => `${cliCommandFrom(import.meta.url)} approve ${actionId.slice(0, 8)} --journal ${resolve(argv.journal)}`,
|
|
1362
|
-
|
|
1367
|
+
// Plain `synartesis` when this is the journal it finds by itself, which
|
|
1368
|
+
// is the usual case; otherwise told where to look.
|
|
1369
|
+
notify: desktopNotifier(
|
|
1370
|
+
process.env,
|
|
1371
|
+
void 0,
|
|
1372
|
+
resolve(argv.journal) === resolve(join(home(), JOURNAL_NAME)) ? cliCommandFrom(import.meta.url) : `${cliCommandFrom(import.meta.url)} --journal ${resolve(argv.journal)}`
|
|
1373
|
+
)
|
|
1363
1374
|
});
|
|
1364
1375
|
proxy2.ready.then((runId) => {
|
|
1365
1376
|
for (const [server, started] of startedWith) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "synartesis",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "An undo layer for AI agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -58,6 +58,8 @@
|
|
|
58
58
|
"demo": "tsup --silent && ./demo/filesystem-demo.sh",
|
|
59
59
|
"demo:memory": "tsup --silent && ./demo/memory-demo.sh",
|
|
60
60
|
"check": "pnpm build && pnpm typecheck && pnpm lint && pnpm test",
|
|
61
|
+
"stress": "tsup --silent && SYNARTESIS_STRESS_SESSIONS=500 SYNARTESIS_KILL_ROUNDS=25 SYNARTESIS_RACE_ROUNDS=10 vitest run tests/stress-undo.test.ts tests/kill-undo.test.ts tests/race-approval.test.ts",
|
|
62
|
+
"coverage": "tsup --silent && vitest run --testTimeout=240000 --coverage --coverage.provider=v8 --coverage.include='src/**' --coverage.reportOnFailure",
|
|
61
63
|
"prepublishOnly": "pnpm check"
|
|
62
64
|
},
|
|
63
65
|
"dependencies": {
|
|
@@ -93,6 +95,7 @@
|
|
|
93
95
|
"@types/react": "^19.3.0",
|
|
94
96
|
"@types/react-dom": "^19.3.0",
|
|
95
97
|
"@vitejs/plugin-react": "^6.1.1",
|
|
98
|
+
"@vitest/coverage-v8": "4.1.11",
|
|
96
99
|
"electron": "^44.3.0",
|
|
97
100
|
"electron-builder": "^26.15.3",
|
|
98
101
|
"eslint": "^10.8.1",
|