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 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
- ```bash
96
- npm install -g synartesis
97
- ```
98
-
99
- Then, from anywhere:
100
-
101
- ```bash
102
- synartesis install
103
- ```
104
-
105
- That finds what Claude Code, Claude Desktop, Cursor, Codex, Gemini CLI, Copilot
106
- CLI, Antigravity or Devin Desktop (Windsurf) already list,
107
- writes one policy covering all of it, and points each entry at the proxy.
108
- Servers it recognises get the policy that ships for them and work immediately;
109
- the rest are drafted with every tool held until you say how to undo it. Your
110
- config is copied aside first, `synartesis uninstall` puts it back, and
111
- `synartesis status` says what is covered.
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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.prepare("INSERT INTO runs (id, label, started_at, status) VALUES (?, ?, ?, 'active')").run(id, label ?? null, (/* @__PURE__ */ new Date()).toISOString());
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 in current)) {
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
- available.set(upstream.name, new Set((await toolShapes(upstream)).map((tool) => tool.name)));
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
- return sites;
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-AEEKBR5D.js.map
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-AEEKBR5D.js";
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", "and for an hour")} ${keyHint("d", "deny")} ${keyHint("j/k", "move")} ${keyHint("q", "quit")}` : ` ${style.quiet(`${options.approveWith} approve --all`)}`
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 lastSeen = seen.get(server);
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.map((action, index) => {
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
- return ` ${here ? style.accent(CURSOR) : " "} ${shown} ${args}`;
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", "and for an hour"), keyHint2("d", "deny"), keyHint2("j/k", "move"), keyHint2("r", "runs")] : screen.mode === "run" ? [
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", "held")
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. Live activity,
2068
- what is waiting for you, and
2069
- undo -- all in one place, with
2070
- the arrow keys. Everything
2071
- below can be done from it.
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
- const shapes = /* @__PURE__ */ new Map();
2373
+ let shapes = /* @__PURE__ */ new Map();
2258
2374
  const upstreams = [];
2259
2375
  try {
2260
- for (const [name, spec] of Object.entries(manifest.servers)) {
2261
- const upstream = await startAsTheClientWould(path, name, spec);
2262
- upstreams.push(upstream);
2263
- shapes.set(name, await toolShapes(upstream));
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
- for (const [name, spec] of Object.entries(manifest.servers)) {
2325
- upstreams.push(await startAsTheClientWould(path, name, spec));
2326
- }
2327
- await verifyAgainstServers(upstreams, manifest);
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 = await toolShapes(upstream);
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 was connected; see the reasons above";
2721
+ return skipped.length === 0 ? "nothing needed connecting" : `not connected -- ${skipped.join("; ")}`;
2599
2722
  }
2600
- return `connected ${String(count)}${count === 1 ? " server" : " servers"} \xB7 restart the client to pick it up`;
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 upstreams = [];
3667
- const missing = [];
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-AEEKBR5D.js";
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?.warn(
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 { manifest: served, disabled } = await withoutMissingTools(upstreams, manifest);
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(await Promise.all(upstreams.map(async (upstream) => [
1337
+ new Map(upstreams.map((upstream) => [
1333
1338
  upstream.name,
1334
- (await toolShapes(upstream)).filter((tool) => !(tool.readOnly === true && trustsMarks(served, upstream.name))).map((tool) => tool.name)
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
- notify: desktopNotifier()
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.0",
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",