synartesis 0.9.1 → 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,54 @@
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
+
5
53
  ## 0.9.1 — 2026-09-24
6
54
 
7
55
  This release attacks undo instead of describing it. The new tests generate the
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
 
@@ -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,13 +51,13 @@ function launch(command, args) {
51
51
  } catch {
52
52
  }
53
53
  }
54
- function desktopNotifier(env = process.env, os = platform()) {
54
+ function desktopNotifier(env = process.env, os = platform(), open) {
55
55
  if (env["SYNARTESIS_NOTIFY"] === "0") {
56
56
  return SILENT;
57
57
  }
58
58
  if (os === "darwin") {
59
59
  return (notice) => {
60
- const { title, body } = words(notice);
60
+ const { title, body } = words(notice, open);
61
61
  launch("osascript", [
62
62
  "-e",
63
63
  "on run argv",
@@ -73,7 +73,7 @@ function desktopNotifier(env = process.env, os = platform()) {
73
73
  }
74
74
  if (os === "linux") {
75
75
  return (notice) => {
76
- const { title, body } = words(notice);
76
+ const { title, body } = words(notice, open);
77
77
  launch("notify-send", ["--app-name=Synartesis", "--", title, unmarked(body)]);
78
78
  };
79
79
  }
@@ -439,6 +439,19 @@ CREATE TABLE IF NOT EXISTS allows (
439
439
  );
440
440
  CREATE INDEX IF NOT EXISTS allows_current ON allows(server, tool, until);
441
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
+
442
455
  CREATE INDEX IF NOT EXISTS actions_by_run ON actions(run_id, seq);
443
456
 
444
457
  -- Deliberately not a schema version bump. Adding an index changes no row and
@@ -752,7 +765,10 @@ var SqliteJournal = class {
752
765
  beginRun(label) {
753
766
  const id = crypto.randomUUID();
754
767
  this.#run("beginRun", () => {
755
- 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
+ })();
756
772
  });
757
773
  return id;
758
774
  }
@@ -1230,6 +1246,64 @@ var SqliteJournal = class {
1230
1246
  close() {
1231
1247
  this.#db.close();
1232
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
+ }
1233
1307
  prunableRuns(before) {
1234
1308
  return this.#run(
1235
1309
  "prunableRuns",
@@ -1285,7 +1359,9 @@ var SqliteJournal = class {
1285
1359
  const dropDenials = this.#db.prepare(
1286
1360
  "DELETE FROM denials WHERE action_id IN (SELECT id FROM actions WHERE run_id = ?)"
1287
1361
  );
1362
+ const dropOwner = this.#db.prepare("DELETE FROM run_owners WHERE run_id = ?");
1288
1363
  for (const id of ids) {
1364
+ dropOwner.run(id);
1289
1365
  dropServers.run(id);
1290
1366
  dropLeases.run(id);
1291
1367
  dropDenials.run(id);
@@ -3908,6 +3984,8 @@ export {
3908
3984
  cliCommand,
3909
3985
  cliCommandFrom,
3910
3986
  proxyCommand,
3987
+ JOURNAL_NAME,
3988
+ home,
3911
3989
  findManifest,
3912
3990
  findJournal,
3913
3991
  counted,
@@ -3973,4 +4051,4 @@ export {
3973
4051
  applyUninstall,
3974
4052
  clientEnvFor
3975
4053
  };
3976
- //# sourceMappingURL=chunk-H4SPMXQW.js.map
4054
+ //# sourceMappingURL=chunk-MTPUG26S.js.map
package/dist/cli.js CHANGED
@@ -62,7 +62,7 @@ import {
62
62
  verifyAgainstServers,
63
63
  warnUntested,
64
64
  wasRefused
65
- } from "./chunk-H4SPMXQW.js";
65
+ } from "./chunk-MTPUG26S.js";
66
66
  import {
67
67
  DriftConflict,
68
68
  ManifestError,
@@ -1126,7 +1126,12 @@ function render(journal, options, tick, view) {
1126
1126
  });
1127
1127
  out2.push("");
1128
1128
  out2.push(
1129
- 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.")}`
1130
1135
  );
1131
1136
  }
1132
1137
  if (view.notice !== "") {
@@ -1193,6 +1198,8 @@ async function watch(options) {
1193
1198
  const waiting = ready.listGated();
1194
1199
  const action = waiting[Math.min(view.cursor, waiting.length - 1)];
1195
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;
1196
1203
  return;
1197
1204
  }
1198
1205
  const changed = approve ? ready.approve(action.id, options.decideAs) : ready.denyByPerson(action.id, options.decideAs, "denied from the watch view");
@@ -1227,6 +1234,10 @@ async function watch(options) {
1227
1234
  view.cursor = Math.max(0, view.cursor - 1);
1228
1235
  return;
1229
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
+ }
1230
1241
  return;
1231
1242
  }
1232
1243
  };
@@ -1570,12 +1581,13 @@ function gatesView(journal, screen, options) {
1570
1581
  return [` ${style.quiet("Nothing is waiting for a decision.")}`];
1571
1582
  }
1572
1583
  const at = Math.min(screen.cursor, waiting.length - 1);
1573
- return waiting.map((action, index) => {
1584
+ return waiting.flatMap((action, index) => {
1574
1585
  const here = index === at && canPress(options);
1575
1586
  const name = `${action.server}.${action.tool}`;
1576
1587
  const shown = here ? style.accent(name) : style.quiet(name);
1577
1588
  const args = style.quiet(truncate2(JSON.stringify(action.args), 54));
1578
- 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];
1579
1591
  });
1580
1592
  }
1581
1593
  function footer(screen, options) {
@@ -1586,7 +1598,7 @@ function footer(screen, options) {
1586
1598
  const what = screen.confirmingForce ? `undo ${screen.confirmingLabel ?? "this session"} anyway, losing that change?` : `undo ${screen.confirmingLabel ?? "this session"}?`;
1587
1599
  return ["", ` ${style.accent(what)} ${keyHint2("y", "yes")} ${keyHint2("n", "no")}`];
1588
1600
  }
1589
- 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" ? [
1590
1602
  keyHint2("l", "check now"),
1591
1603
  keyHint2("p", "preview undo"),
1592
1604
  keyHint2("u", "undo"),
@@ -1597,10 +1609,38 @@ function footer(screen, options) {
1597
1609
  keyHint2("l", "check now"),
1598
1610
  keyHint2("u", "undo"),
1599
1611
  keyHint2("j/k", "move"),
1600
- keyHint2("g", "held")
1612
+ keyHint2("g", "waiting calls")
1601
1613
  ];
1602
1614
  return ["", ` ${keys.join(" ")} ${keyHint2("q", "quit")}`];
1603
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
+ }
1604
1644
  function waitingForJournal2(options, tick) {
1605
1645
  const spinner = options.live ? `${style.accent(FRAMES2[tick % FRAMES2.length] ?? "")} ` : "";
1606
1646
  return [
@@ -1655,7 +1695,8 @@ async function openConsole(options) {
1655
1695
  groups: [],
1656
1696
  busy: void 0,
1657
1697
  notice: "",
1658
- noticeUntil: 0
1698
+ noticeUntil: 0,
1699
+ welcome: false
1659
1700
  };
1660
1701
  let tick = 0;
1661
1702
  const stopped = () => screen.stop;
@@ -1666,15 +1707,30 @@ async function openConsole(options) {
1666
1707
  const frame = () => {
1667
1708
  const ready = open();
1668
1709
  if (ready === void 0) {
1710
+ if (options.scan !== void 0 && canPress(options)) {
1711
+ return welcomeFrame(screen, options, tick);
1712
+ }
1669
1713
  return waitingForJournal2(options, tick);
1670
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
+ }
1671
1721
  const tail = command(ready);
1672
1722
  const shout = screen.notice === "" ? 0 : screen.notice.split("\n").length;
1673
1723
  const room = Math.max(3, roomFor(options) - tail.length - shout);
1674
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);
1675
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
+ ];
1676
1731
  return [
1677
1732
  ...header(options, screen, tick),
1733
+ ...alert,
1678
1734
  ...body,
1679
1735
  ...notice,
1680
1736
  ...tail,
@@ -1798,6 +1854,13 @@ async function openConsole(options) {
1798
1854
  const rescan = () => {
1799
1855
  screen.groups = options.scan?.() ?? [];
1800
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
+ }
1801
1864
  const connect = async (targets) => {
1802
1865
  if (options.connect === void 0) {
1803
1866
  say("no way to connect was configured");
@@ -1981,6 +2044,9 @@ async function openConsole(options) {
1981
2044
  return;
1982
2045
  }
1983
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
+ }
1984
2050
  return;
1985
2051
  }
1986
2052
  };
@@ -1998,6 +2064,9 @@ async function openConsole(options) {
1998
2064
  return;
1999
2065
  }
2000
2066
  press(next.value);
2067
+ if (screen.welcome && open() === void 0) {
2068
+ screen.mode = "connections";
2069
+ }
2001
2070
  if (stopped()) {
2002
2071
  return;
2003
2072
  }
@@ -2108,11 +2177,12 @@ if (NODE_MAJOR < 22) {
2108
2177
  process.exit(2);
2109
2178
  }
2110
2179
  var COMMANDS = `
2111
- synartesis start here. Live activity,
2112
- what is waiting for you, and
2113
- undo -- all in one place, with
2114
- the arrow keys. Everything
2115
- 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.
2116
2186
  synartesis install [--client <name>] [--remote] [--dry-run] [--print]
2117
2187
  synartesis uninstall [--client <name>]
2118
2188
  synartesis status
@@ -2123,6 +2193,8 @@ var COMMANDS = `
2123
2193
  synartesis show <runId> [--full] [--live] [--journal <path>]
2124
2194
  synartesis gates [--journal <path>]
2125
2195
  synartesis close [runId] [--journal <path>]
2196
+ synartesis clean [--dry-run] [--yes] clear finished sessions,
2197
+ keep what still matters
2126
2198
  synartesis prune [--older-than <days>] [--dry-run] [--journal <path>]
2127
2199
  synartesis proxy --manifest <path> [--server <name>] what your agent runs
2128
2200
  [--journal <path>]
@@ -2644,10 +2716,16 @@ async function connectThese(targets, manifestPath) {
2644
2716
  );
2645
2717
  const applied = applyInstall(plans, manifestPath, yaml);
2646
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}`));
2647
2720
  if (count === 0) {
2648
- return "nothing was connected; see the reasons above";
2721
+ return skipped.length === 0 ? "nothing needed connecting" : `not connected -- ${skipped.join("; ")}`;
2649
2722
  }
2650
- 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");
2651
2729
  }
2652
2730
  function runStatus(argv) {
2653
2731
  const manifestPath = findManifest(flag(argv, "--manifest"));
@@ -2934,10 +3012,15 @@ function runList(journal, asJson, journalPath) {
2934
3012
  }
2935
3013
  hint(firstOf(
2936
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
+ },
2937
3019
  () => whatChanged(journal)
2938
3020
  ));
2939
3021
  return 0;
2940
3022
  }
3023
+ var CLEAN_NAG_SESSIONS = 10;
2941
3024
  async function runShow(argv, journal, asJson) {
2942
3025
  const full = argv.includes("--full");
2943
3026
  const runs = [...journal.listRuns()].reverse();
@@ -3227,6 +3310,66 @@ function runPrune(argv, journal, journalPath) {
3227
3310
  out("");
3228
3311
  return 0;
3229
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
+ }
3230
3373
  function runResolve(argv, journal) {
3231
3374
  const applied = argv.includes("--applied");
3232
3375
  const failed = argv.includes("--failed");
@@ -3914,6 +4057,7 @@ var KNOWN_COMMANDS = [
3914
4057
  "show",
3915
4058
  "gates",
3916
4059
  "close",
4060
+ "clean",
3917
4061
  "prune",
3918
4062
  "proxy",
3919
4063
  "desktop",
@@ -4139,6 +4283,8 @@ ${COMMANDS}`);
4139
4283
  return runClose(argv, journal);
4140
4284
  case "prune":
4141
4285
  return runPrune(argv, journal, journalPath);
4286
+ case "clean":
4287
+ return await runClean(argv, journal, journalPath);
4142
4288
  case "gates":
4143
4289
  return runGates(journal, asJson);
4144
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,6 +14,7 @@ import {
13
14
  findJournal,
14
15
  findManifest,
15
16
  fingerprint2 as fingerprint,
17
+ home,
16
18
  isDisconnected,
17
19
  listAll,
18
20
  loadManifest,
@@ -34,7 +36,7 @@ import {
34
36
  warnUntested,
35
37
  withIdempotencyKey,
36
38
  withoutMissingTools
37
- } from "./chunk-H4SPMXQW.js";
39
+ } from "./chunk-MTPUG26S.js";
38
40
  import {
39
41
  SnapshotError,
40
42
  UpstreamError,
@@ -42,7 +44,7 @@ import {
42
44
  } from "./chunk-YVOO3PTV.js";
43
45
 
44
46
  // src/proxy/stdio.ts
45
- import { resolve } from "path";
47
+ import { join, resolve } from "path";
46
48
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
47
49
 
48
50
  // src/proxy/http.ts
@@ -642,7 +644,7 @@ function createProxyServer(options) {
642
644
  }
643
645
  tools.push({
644
646
  ...compatible(tool, (dialect) => {
645
- log?.warn(
647
+ log?.debug(
646
648
  `${upstream.name}.${tool.name} declares JSON Schema ${dialect}; the dialect was dropped so clients that only accept 2020-12 can call it`
647
649
  );
648
650
  }),
@@ -1362,7 +1364,13 @@ async function main() {
1362
1364
  logger: log,
1363
1365
  // Absolute, because whoever approves may be in any directory at all.
1364
1366
  approveHint: (actionId) => `${cliCommandFrom(import.meta.url)} approve ${actionId.slice(0, 8)} --journal ${resolve(argv.journal)}`,
1365
- 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
+ )
1366
1374
  });
1367
1375
  proxy2.ready.then((runId) => {
1368
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.1",
3
+ "version": "0.9.2",
4
4
  "description": "An undo layer for AI agents.",
5
5
  "type": "module",
6
6
  "private": false,