synartesis 0.8.8 → 0.9.1

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/dist/cli.js CHANGED
@@ -1,368 +1,238 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ CLIENT_IDS,
4
+ ConfigError,
3
5
  IDEMPOTENCY_META_KEY,
4
6
  LIVE_IS_NOT_RECOVERY,
7
+ LOOKED_FOR,
5
8
  NOTHING_RECORDED_YET,
6
9
  PROXY_FLAGS,
7
10
  WORDMARK,
11
+ applyInstall,
12
+ applyUninstall,
8
13
  banner,
14
+ canNotify,
9
15
  canonical,
10
16
  cliCommand,
11
- connectStdioUpstream,
17
+ clientEnvFor,
18
+ connectUpstream,
12
19
  counted,
13
20
  createPolicyResolver,
14
21
  createRouter,
15
22
  describeStanding,
23
+ desktopNotifier,
24
+ differing,
25
+ discover,
26
+ draftManifest,
16
27
  errorStyle,
17
28
  findJournal,
18
29
  findManifest,
30
+ fingerprint,
31
+ fingerprint2,
32
+ invokerFor,
33
+ isClientId,
34
+ isWrapped,
19
35
  labelFor,
36
+ listAll,
20
37
  loadManifest,
21
38
  observeState,
22
39
  openJournal,
23
40
  parseManifest,
24
- pathBinaryMatches,
25
41
  pinBlock,
42
+ planInstall,
26
43
  planInverse,
27
44
  planRead,
28
45
  proxyCommand,
29
46
  qualify,
30
47
  resolvedRead,
31
48
  rule,
49
+ serversAt,
50
+ splitQualified,
32
51
  standing,
52
+ startAll,
53
+ startTogether,
33
54
  style,
34
55
  toPayload,
35
56
  toResolvedRead,
36
57
  toolShapes,
58
+ trustsMarks,
37
59
  ungoverned,
38
60
  untested,
61
+ upstreamEnv,
39
62
  verifyAgainstServers,
40
63
  warnUntested,
41
64
  wasRefused
42
- } from "./chunk-JE7MOCZO.js";
65
+ } from "./chunk-H4SPMXQW.js";
43
66
  import {
44
67
  DriftConflict,
45
68
  ManifestError,
46
69
  RollbackHalted,
47
70
  SynartesisError,
48
- UpstreamError,
49
71
  changedLines,
50
72
  describe
51
73
  } from "./chunk-YVOO3PTV.js";
52
74
 
53
75
  // src/cli.ts
76
+ import { platform } from "os";
54
77
  import { spawn } from "child_process";
55
- import { existsSync as existsSync8, mkdirSync as mkdirSync2, readFileSync as readFileSync4, statSync, writeFileSync as writeFileSync3 } from "fs";
56
- import { dirname as dirname3, join as join3, resolve as resolve3 } from "path";
57
- import { fileURLToPath as fileURLToPath2 } from "url";
58
-
59
- // src/init/draft.ts
60
- import { z } from "zod";
61
-
62
- // src/init/known.ts
63
- import { existsSync, readFileSync } from "fs";
78
+ import { existsSync as existsSync5, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "fs";
79
+ import { createInterface } from "readline/promises";
80
+ import { dirname, join as join2, resolve } from "path";
64
81
  import { fileURLToPath } from "url";
65
- var KNOWN = [
66
- { marker: "server-filesystem", manifest: "filesystem" },
67
- { marker: "server-github", manifest: "github" },
68
- { marker: "github-mcp-server", manifest: "github" },
69
- { marker: "server-memory", manifest: "memory" },
70
- { marker: "mcp-server-git", manifest: "git" },
71
- { marker: "server-git", manifest: "git" }
72
- ];
73
- function manifestsDir() {
74
- for (const up of ["../manifests/", "../../manifests/"]) {
75
- const candidate = fileURLToPath(new URL(up, import.meta.url));
76
- if (existsSync(candidate)) {
77
- return candidate;
78
- }
79
- }
80
- return void 0;
81
- }
82
- function knownPolicyFor(command, args) {
83
- const line2 = [command, ...args].join(" ");
84
- const hit = KNOWN.find((entry) => line2.includes(entry.marker));
85
- const dir = manifestsDir();
86
- if (hit === void 0 || dir === void 0) {
87
- return void 0;
88
- }
89
- const path = `${dir}${hit.manifest}.yaml`;
90
- if (!existsSync(path)) {
91
- return void 0;
92
- }
93
- try {
94
- const text = readFileSync(path, "utf8");
95
- const source = toolsBlock(text);
96
- const key = serverKey(text);
97
- if (source === void 0 || key === void 0) {
98
- return void 0;
82
+
83
+ // src/manifest/edit.ts
84
+ import { isMap, isNode, isScalar, isSeq, parseDocument, stringify } from "yaml";
85
+ var PolicyEditError = class extends Error {
86
+ name = "PolicyEditError";
87
+ };
88
+ function allowAlways(input) {
89
+ const { text, file, server, tool } = input;
90
+ const qualified = `${server}.${tool}`;
91
+ const before = parseManifest(text, file);
92
+ if (before.servers[server] === void 0) {
93
+ throw new PolicyEditError(`${file} has no server called ${server}`);
94
+ }
95
+ const current = createPolicyResolver(before).resolve(qualified);
96
+ const doc = parseDocument(text, { keepSourceTokens: true });
97
+ const tools = doc.get("tools", true);
98
+ const exact = isSeq(tools) ? tools.items.find((item) => isMap(item) && item.get("match") === qualified) : void 0;
99
+ let edited;
100
+ let how;
101
+ if (exact !== void 0) {
102
+ if (!isMap(exact) || exact.flow === true) {
103
+ throw new PolicyEditError(`the rule for ${qualified} is written on one line; change its gate by hand`);
104
+ }
105
+ const gate = exact.items.find((pair) => isScalar(pair.key) && pair.key.value === "gate");
106
+ if (gate !== void 0 && isScalar(gate.value) && gate.value.value === "never") {
107
+ how = "already";
108
+ edited = text;
109
+ } else if (gate !== void 0) {
110
+ const at = rangeOf(gate.value, qualified);
111
+ edited = text.slice(0, at[0]) + "never" + text.slice(at[1]);
112
+ how = "changed";
113
+ } else {
114
+ const cls = exact.items.find((pair) => isScalar(pair.key) && pair.key.value === "class");
115
+ const key = cls?.key;
116
+ if (cls === void 0 || !isScalar(key)) {
117
+ throw new PolicyEditError(`the rule for ${qualified} has no class; add one by hand first`);
118
+ }
119
+ const keyAt = rangeOf(key, qualified)[0];
120
+ const indent = " ".repeat(keyAt - lineStart(text, keyAt));
121
+ const lineEnd = endOfLine(text, rangeOf(cls.value, qualified)[1]);
122
+ edited = `${text.slice(0, lineEnd)}
123
+ ${indent}gate: never${text.slice(lineEnd)}`;
124
+ how = "changed";
99
125
  }
100
- const rules = parseManifest(
101
- `version: 1
102
- servers:
103
- ${key}:
104
- command: "true"
126
+ } else {
127
+ if (tools !== void 0 && (!isSeq(tools) || tools.flow === true || tools.items.length === 0)) {
128
+ throw new PolicyEditError(`the tools list in ${file} is not written one rule to a line; add the rule by hand`);
129
+ }
130
+ const base = current.matched ? { ...current.policy } : { class: "irreversible" };
131
+ delete base["match"];
132
+ delete base["refusal"];
133
+ const rule2 = { match: qualified, ...base, gate: "never" };
134
+ const said = [`# Let through without asking, by ${input.by} on ${input.date}.`];
135
+ if (base["class"] === "irreversible") {
136
+ said.push("# It still cannot be undone: it is recorded, and no longer held.");
137
+ }
138
+ const body = stringify([rule2], { lineWidth: 0 }).trimEnd().replace(/^- match: .*$/m, `- match: ${JSON.stringify(qualified)}`);
139
+ if (isSeq(tools)) {
140
+ const seqAt = rangeOf(tools, "tools")[0];
141
+ const indent = " ".repeat(seqAt - lineStart(text, seqAt));
142
+ const lines = [...said, ...body.split("\n")].map((line2) => indent + line2);
143
+ edited = insertAfter(text, rangeOf(tools, "tools")[2], `
144
+ ${lines.join("\n")}
145
+ `);
146
+ } else {
147
+ edited = `${text.replace(/\n*$/, "\n")}
105
148
  tools:
106
- ${source}
107
- `,
108
- path
109
- ).tools;
110
- const claimed = /^\s*provenance:\s*(live|documented)\s*$/m.exec(text)?.[1];
111
- return {
112
- key,
113
- rules,
114
- name: hit.manifest,
115
- source,
116
- ...claimed === "live" || claimed === "documented" ? { provenance: claimed } : {}
117
- };
118
- } catch {
119
- return void 0;
120
- }
121
- }
122
- function toolsBlock(text) {
123
- const at = text.search(/^tools:[ \t]*$/m);
124
- if (at === -1) {
125
- return void 0;
149
+ ${[...said, ...body.split("\n")].map((line2) => ` ${line2}`).join("\n")}
150
+ `;
151
+ }
152
+ how = "added";
126
153
  }
127
- const body = text.slice(text.indexOf("\n", at) + 1);
128
- const lines = [];
129
- for (const line2 of body.split("\n")) {
130
- if (/^[^\s#]/.test(line2)) {
131
- break;
154
+ const pins = before.pins?.[server];
155
+ if (how !== "already" && pins !== void 0 && pins[tool] === void 0) {
156
+ if (input.pin === void 0) {
157
+ throw new PolicyEditError(`${server} is pinned, and ${tool} has no pin yet`);
132
158
  }
133
- lines.push(line2);
159
+ edited = addPin(edited, server, tool, input.pin, qualified);
134
160
  }
135
- return lines.join("\n").replace(/\s+$/, "");
161
+ const after = parseManifest(edited, file);
162
+ const policy = createPolicyResolver(after).resolve(qualified).policy;
163
+ confirmOnly(before, after, { server, tool }, current.policy, policy, input.pin);
164
+ return { text: edited, how, policy };
136
165
  }
137
- function serverKey(text) {
138
- const at = text.search(/^servers:[ \t]*$/m);
139
- if (at === -1) {
140
- return void 0;
141
- }
142
- const body = text.slice(text.indexOf("\n", at) + 1);
143
- for (const line2 of body.split("\n")) {
144
- if (/^[^\s#]/.test(line2)) {
145
- return void 0;
146
- }
147
- const named = /^ {2}([A-Za-z0-9_-]+):[ \t]*$/.exec(line2);
148
- if (named?.[1] !== void 0) {
149
- return named[1];
150
- }
166
+ function rangeOf(node, what) {
167
+ const range = isNode(node) ? node.range : void 0;
168
+ if (range === void 0 || range === null) {
169
+ throw new PolicyEditError(`could not find where ${what} is written in the policy`);
151
170
  }
152
- return void 0;
171
+ return range;
153
172
  }
154
- function toolsReferencedBy(rule2, key) {
155
- const local = (qualified) => qualified === void 0 || !qualified.startsWith(`${key}.`) ? void 0 : qualified.slice(key.length + 1);
156
- return [local(rule2.snapshot?.tool), local(rule2.inverse?.tool)].filter(
157
- (name) => name !== void 0
158
- );
173
+ function lineStart(text, at) {
174
+ return text.lastIndexOf("\n", at - 1) + 1;
159
175
  }
160
-
161
- // src/init/draft.ts
162
- var toolSchema = z.looseObject({
163
- name: z.string(),
164
- description: z.string().optional(),
165
- annotations: z.looseObject({
166
- readOnlyHint: z.boolean().optional(),
167
- destructiveHint: z.boolean().optional(),
168
- idempotentHint: z.boolean().optional()
169
- }).optional()
170
- });
171
- var listSchema = z.looseObject({
172
- tools: z.array(toolSchema),
173
- nextCursor: z.string().optional()
174
- });
175
- function quote(value) {
176
- return JSON.stringify(value);
176
+ function endOfLine(text, at) {
177
+ const next = text.indexOf("\n", Math.max(0, at - 1));
178
+ return next === -1 ? text.length : next;
177
179
  }
178
- function summarise(text) {
179
- if (text === void 0) {
180
- return "";
180
+ function insertAfter(text, at, block2) {
181
+ if (at > 0 && text[at - 1] === "\n") {
182
+ return text.slice(0, at) + block2.slice(1) + text.slice(at);
181
183
  }
182
- const single = text.replace(/\s+/g, " ").trim();
183
- return single.length > 96 ? `${single.slice(0, 93)}...` : single;
184
+ const end = endOfLine(text, at);
185
+ return text.slice(0, end) + block2.replace(/\n$/, "") + text.slice(end);
184
186
  }
185
- function draftTool(server, tool) {
186
- const match = `${server}.${tool.name}`;
187
- const lines = [];
188
- const description = summarise(tool.description);
189
- if (description !== "") {
190
- lines.push(` # ${description}`);
191
- }
192
- if (tool.annotations?.readOnlyHint === true) {
193
- lines.push(` # classified readonly from the server's readOnlyHint; verify it before relying on it.`);
194
- lines.push(` - match: ${quote(match)}`);
195
- lines.push(` class: readonly`);
196
- return lines.join("\n");
197
- }
198
- lines.push(` # TODO: this is gated on every call until you describe how to undo it.`);
199
- lines.push(` # reversible needs a snapshot (a pre-read) and an inverse.`);
200
- lines.push(` # compensable needs an inverse only, usually built from $result.`);
201
- lines.push(` # irreversible is correct when neither exists; leave gate: always.`);
202
- lines.push(` - match: ${quote(match)}`);
203
- lines.push(` class: irreversible`);
204
- lines.push(` gate: always`);
205
- return lines.join("\n");
206
- }
207
- function patternFor(match) {
208
- const source = match.split("*").map((literal) => literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[^.]*");
209
- return new RegExp(`^${source}$`);
210
- }
211
- function adopt(known, name, tools) {
212
- const advertised = new Set(tools.map((tool) => tool.name));
213
- const covered = /* @__PURE__ */ new Set();
214
- for (const rule2 of known.rules) {
215
- for (const needed of toolsReferencedBy(rule2, known.key)) {
216
- if (!advertised.has(needed)) {
217
- return void 0;
218
- }
219
- }
220
- const test = patternFor(rule2.match);
221
- for (const tool of tools) {
222
- if (test.test(`${known.key}.${tool.name}`)) {
223
- covered.add(tool.name);
224
- }
225
- }
226
- }
227
- if (covered.size === 0) {
228
- return void 0;
187
+ function addPin(text, server, tool, pin, qualified) {
188
+ const doc = parseDocument(text, { keepSourceTokens: true });
189
+ const map = doc.getIn(["pins", server], true);
190
+ if (!isMap(map) || map.flow === true || map.items.length === 0) {
191
+ throw new PolicyEditError(`the pins for ${server} are not written one to a line; pin ${qualified} by hand`);
229
192
  }
230
- const renamed = known.source.replaceAll(`"${known.key}.`, `"${name}.`);
231
- const missing = tools.filter((tool) => !covered.has(tool.name));
232
- const extra = missing.length === 0 ? "" : [
233
- "",
234
- ` # Not mentioned by the bundled ${known.name} policy, so gated until you say otherwise.`,
235
- ...missing.map((tool) => draftTool(name, tool))
236
- ].join("\n");
237
- return { source: `${renamed}${extra}`, covered: covered.size };
238
- }
239
- async function draftManifest(options) {
240
- const upstream = await connectStdioUpstream({
241
- name: options.name,
242
- command: options.command,
243
- args: options.args,
244
- stderr: "capture"
245
- });
246
- let tools;
247
- try {
248
- const collected = [];
249
- let cursor;
250
- do {
251
- const page = listSchema.parse(
252
- await upstream.client.request(
253
- { method: "tools/list", params: cursor === void 0 ? {} : { cursor } },
254
- z.looseObject({})
255
- )
256
- );
257
- collected.push(...page.tools);
258
- cursor = page.nextCursor;
259
- } while (cursor !== void 0);
260
- tools = collected;
261
- } catch (error) {
262
- throw new UpstreamError(options.name, "tools/list", error);
263
- } finally {
264
- await upstream.close();
193
+ const at = rangeOf(map, "pins")[0];
194
+ const indent = " ".repeat(at - lineStart(text, at));
195
+ return insertAfter(text, rangeOf(map, "pins")[2], `
196
+ ${indent}${tool}: ${JSON.stringify(pin)}
197
+ `);
198
+ }
199
+ function confirmOnly(before, after, { server, tool }, was, now, pin) {
200
+ const qualified = `${server}.${tool}`;
201
+ const refuse = (why) => {
202
+ throw new PolicyEditError(`the edit came out wrong (${why}), so nothing was written`);
203
+ };
204
+ if (now.match !== qualified || now.gate !== "never") {
205
+ refuse(`${qualified} is not let through`);
265
206
  }
266
- if (tools.length === 0) {
267
- throw new ManifestError(`${options.name} exposes no tools, so there is no policy to write`);
207
+ if (now.class !== was.class) {
208
+ refuse(`${qualified} would change from ${was.class} to ${now.class}`);
268
209
  }
269
- const existing = options.existing?.trimEnd();
270
- if (existing !== void 0 && existing.includes(`
271
- ${options.name}:`)) {
272
- throw new ManifestError(
273
- `${options.name} is already declared in the manifest; remove it first or choose another name`
274
- );
210
+ const others = (manifest) => manifest.tools.filter((rule2) => rule2.match !== qualified).map((rule2) => canonical(rule2));
211
+ if (canonical(others(before)) !== canonical(others(after))) {
212
+ refuse("another rule changed");
275
213
  }
276
- const known = knownPolicyFor(options.command, options.args);
277
- const claim = known?.provenance === void 0 ? [] : [
278
- ...known.provenance === "documented" ? [
279
- ` # This policy has never been run against the real server. Check it`,
280
- ` # against your own setup before trusting undo on it.`
281
- ] : [],
282
- ` provenance: ${known.provenance}`
283
- ];
284
- const server = [
285
- ` ${options.name}:`,
286
- ` command: ${quote(options.command)}`,
287
- ` args: [${options.args.map(quote).join(", ")}]`,
288
- ...claim
289
- ].join("\n");
290
- const adopted = known === void 0 ? void 0 : adopt(known, options.name, tools);
291
- const policies = adopted?.source ?? tools.map((tool) => draftTool(options.name, tool)).join("\n\n");
292
- if (existing === void 0) {
293
- const yaml = [
294
- `# Generated by synartesis init from ${options.name}'s tools/list.`,
295
- ...adopted === void 0 ? [
296
- `# Every tool starts gated. Working through the TODOs is the whole job:`,
297
- `# a tool with no inverse is one an agent cannot use unsupervised.`
298
- ] : [
299
- `# ${String(adopted.covered)} of its tools were recognised, so the policy that ships`,
300
- `# with Synartesis for ${known?.name ?? "this server"} was used and checked against what this`,
301
- `# server actually advertises. Read it before trusting it: it is a starting`,
302
- `# point that happens to be finished, not a promise about your setup.`
303
- ],
304
- ``,
305
- `version: 1`,
306
- ``,
307
- `servers:`,
308
- server,
309
- ``,
310
- `tools:`,
311
- policies,
312
- ``
313
- ].join("\n");
314
- return adopted === void 0 || known === void 0 ? { yaml } : {
315
- yaml,
316
- adopted: {
317
- server: known.name,
318
- tools: adopted.covered,
319
- ...known.provenance === void 0 ? {} : { provenance: known.provenance }
320
- }
321
- };
214
+ if (canonical(before.servers) !== canonical(after.servers)) {
215
+ refuse("a server changed");
322
216
  }
323
- const merged = mergeInto(existing, server, policies, options.name);
324
- return adopted === void 0 || known === void 0 ? { yaml: merged } : {
325
- yaml: merged,
326
- adopted: {
327
- server: known.name,
328
- tools: adopted.covered,
329
- ...known.provenance === void 0 ? {} : { provenance: known.provenance }
330
- }
331
- };
332
- }
333
- function mergeInto(existing, server, policies, name) {
334
- const serversAt2 = existing.indexOf("\nservers:");
335
- const toolsAt = existing.indexOf("\ntools:");
336
- if (serversAt2 === -1 || toolsAt === -1 || toolsAt < serversAt2) {
337
- throw new ManifestError(
338
- "the existing manifest does not have a servers: block followed by a tools: block, so it cannot be extended automatically"
339
- );
217
+ const pinned = before.pins?.[server];
218
+ const expected = pin === void 0 || pinned === void 0 || pinned[tool] !== void 0 ? before.pins : { ...before.pins, [server]: { ...pinned, [tool]: pin } };
219
+ if (canonical(expected ?? null) !== canonical(after.pins ?? null)) {
220
+ refuse("the pins changed");
340
221
  }
341
- const head = existing.slice(0, toolsAt);
342
- const tail = existing.slice(toolsAt);
343
- return [
344
- head.trimEnd(),
345
- server,
346
- tail.trimEnd(),
347
- ``,
348
- ` # --- added by synartesis init for ${name} ---`,
349
- policies,
350
- ``
351
- ].join("\n");
352
222
  }
353
223
 
354
224
  // src/rollback/rollback.ts
355
- import { z as z2 } from "zod";
356
- var inversePlan = z2.object({
357
- server: z2.string(),
358
- tool: z2.string(),
359
- args: z2.record(z2.string(), z2.unknown())
225
+ import { z } from "zod";
226
+ var inversePlan = z.object({
227
+ server: z.string(),
228
+ tool: z.string(),
229
+ args: z.record(z.string(), z.unknown())
360
230
  });
361
- var observation = z2.union([
362
- z2.object({ present: z2.literal(true), value: z2.unknown() }),
363
- z2.object({ present: z2.literal(false) })
231
+ var observation = z.union([
232
+ z.object({ present: z.literal(true), value: z.unknown() }),
233
+ z.object({ present: z.literal(false) })
364
234
  ]);
365
- var toolResult = z2.looseObject({ isError: z2.boolean().default(false) });
235
+ var toolResult = z.looseObject({ isError: z.boolean().default(false) });
366
236
  function sameState(a, b) {
367
237
  return canonical(a) === canonical(b);
368
238
  }
@@ -505,7 +375,11 @@ ${seen}` : seen;
505
375
  }
506
376
  if (sameState(current, recordedPost.data)) {
507
377
  verified = true;
508
- } 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)) {
509
383
  steps.push({
510
384
  ...describeStep(action),
511
385
  kind: "already-reverted",
@@ -582,7 +456,7 @@ ${seen}` : seen;
582
456
  steps.push({
583
457
  ...describeStep(action),
584
458
  kind: "revert",
585
- reason: verified ? "state matches; applying inverse" : forcedOver ?? unverifiedBecause(action),
459
+ reason: verified ? "unchanged since, so safe to put back" : forcedOver ?? unverifiedBecause(action),
586
460
  verified,
587
461
  plan,
588
462
  ...rebuilt.inverse === void 0 ? {} : { replanned: true },
@@ -620,6 +494,19 @@ ${seen}` : seen;
620
494
  journal.markRolledBack(action.id);
621
495
  continue;
622
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
+ }
623
510
  const halt = new RollbackHalted(action.seq, outcome.message);
624
511
  if (outcome.rejected) {
625
512
  journal.markInverseRejected(action.id, halt.message);
@@ -677,6 +564,28 @@ function overwriteText(current, action) {
677
564
  }
678
565
  return changedLines(current, intended2);
679
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
+ }
680
589
  function intendedAfterInverse(action) {
681
590
  return action.snapshot === void 0 ? void 0 : { present: true, value: action.snapshot };
682
591
  }
@@ -696,11 +605,11 @@ async function executeInverse(router, plan, idempotencyKey, signal) {
696
605
  _meta: { [IDEMPOTENCY_META_KEY]: idempotencyKey }
697
606
  }
698
607
  },
699
- z2.looseObject({}),
608
+ z.looseObject({}),
700
609
  { signal }
701
610
  );
702
611
  } catch (error) {
703
- return { ok: false, rejected: false, message: describe(error) };
612
+ return { ok: false, rejected: upstream.classify?.(error) !== void 0, message: describe(error) };
704
613
  }
705
614
  const parsed = toolResult.safeParse(raw);
706
615
  if (parsed.success && parsed.data.isError) {
@@ -714,10 +623,10 @@ async function executeInverse(router, plan, idempotencyKey, signal) {
714
623
  }
715
624
 
716
625
  // src/rollback/inspect.ts
717
- import { z as z3 } from "zod";
718
- var observation2 = z3.union([
719
- z3.object({ present: z3.literal(true), value: z3.unknown() }),
720
- z3.object({ present: z3.literal(false) })
626
+ import { z as z2 } from "zod";
627
+ var observation2 = z2.union([
628
+ z2.object({ present: z2.literal(true), value: z2.unknown() }),
629
+ z2.object({ present: z2.literal(false) })
721
630
  ]);
722
631
  function sameState2(a, b) {
723
632
  return canonical(a) === canonical(b);
@@ -1035,7 +944,7 @@ function shortList() {
1035
944
  }
1036
945
 
1037
946
  // src/watch.ts
1038
- import { existsSync as existsSync2 } from "fs";
947
+ import { existsSync } from "fs";
1039
948
 
1040
949
  // src/keys.ts
1041
950
  var SEQUENCE = /^\u001b(\[[0-9;?]*[ -\/]*[@-~]|O[@-~])/;
@@ -1157,6 +1066,7 @@ function summariseArgs(args, limit = 60) {
1157
1066
  }
1158
1067
 
1159
1068
  // src/watch.ts
1069
+ var HOUR_MS = 60 * 60 * 1e3;
1160
1070
  var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1161
1071
  var NOTICE_TICKS = 26;
1162
1072
  function line(action, now) {
@@ -1216,7 +1126,7 @@ function render(journal, options, tick, view) {
1216
1126
  });
1217
1127
  out2.push("");
1218
1128
  out2.push(
1219
- canDecide(options) ? ` ${keyHint("a", "approve")} ${keyHint("d", "deny")} ${keyHint("j/k", "move")} ${keyHint("q", "quit")}` : ` ${style.quiet(`${options.approveWith} approve --all`)}`
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`)}`
1220
1130
  );
1221
1131
  }
1222
1132
  if (view.notice !== "") {
@@ -1262,7 +1172,7 @@ async function* terminalKeys() {
1262
1172
  async function watch(options) {
1263
1173
  let journal;
1264
1174
  const open = () => {
1265
- if (journal === void 0 && existsSync2(options.journalPath)) {
1175
+ if (journal === void 0 && existsSync(options.journalPath)) {
1266
1176
  journal = openJournal(options.journalPath, { mustExist: true });
1267
1177
  }
1268
1178
  return journal;
@@ -1275,7 +1185,7 @@ async function watch(options) {
1275
1185
  const ready = open();
1276
1186
  return ready === void 0 ? waitingForJournal(options, tick2) : render(ready, options, tick2, view);
1277
1187
  };
1278
- const decide = (approve) => {
1188
+ const decide = (approve, forAnHour = false) => {
1279
1189
  const ready = open();
1280
1190
  if (ready === void 0 || options.decideAs === void 0) {
1281
1191
  return;
@@ -1285,8 +1195,11 @@ async function watch(options) {
1285
1195
  if (action === void 0) {
1286
1196
  return;
1287
1197
  }
1288
- const changed = approve ? ready.approve(action.id, options.decideAs) : ready.deny(action.id, options.decideAs, "denied from the watch view");
1289
- view.notice = !changed ? `${action.server}.${action.tool} was already settled` : approve ? `approved ${action.server}.${action.tool} \xB7 now tell the agent to try again` : `denied ${action.server}.${action.tool} \xB7 it will not go through`;
1198
+ const changed = approve ? ready.approve(action.id, options.decideAs) : ready.denyByPerson(action.id, options.decideAs, "denied from the watch view");
1199
+ if (changed && approve && forAnHour) {
1200
+ ready.allow(action.server, action.tool, options.decideAs, new Date(Date.now() + HOUR_MS).toISOString());
1201
+ }
1202
+ view.notice = !changed ? `${action.server}.${action.tool} was already settled` : approve ? forAnHour ? `approved ${action.server}.${action.tool} \xB7 not asked again for an hour \xB7 tell the agent to try again` : `approved ${action.server}.${action.tool} \xB7 now tell the agent to try again` : `denied ${action.server}.${action.tool} \xB7 the agent is told if it asks again`;
1290
1203
  view.noticeUntil = tick + NOTICE_TICKS;
1291
1204
  view.cursor = 0;
1292
1205
  };
@@ -1299,6 +1212,9 @@ async function watch(options) {
1299
1212
  case "a":
1300
1213
  decide(true);
1301
1214
  return;
1215
+ case "A":
1216
+ decide(true, true);
1217
+ return;
1302
1218
  case "d":
1303
1219
  decide(false);
1304
1220
  return;
@@ -1349,7 +1265,7 @@ async function watch(options) {
1349
1265
  if (options.maxTicks !== void 0 && tick + 1 >= options.maxTicks) {
1350
1266
  break;
1351
1267
  }
1352
- await new Promise((resolve4) => setTimeout(resolve4, interval));
1268
+ await new Promise((resolve2) => setTimeout(resolve2, interval));
1353
1269
  }
1354
1270
  return 0;
1355
1271
  } finally {
@@ -1364,613 +1280,17 @@ async function watch(options) {
1364
1280
  await reader?.return?.(void 0);
1365
1281
  await reading;
1366
1282
  })(),
1367
- new Promise((resolve4) => setTimeout(resolve4, 50).unref())
1283
+ new Promise((resolve2) => setTimeout(resolve2, 50).unref())
1368
1284
  ]);
1369
1285
  journal?.close();
1370
1286
  }
1371
1287
  }
1372
1288
 
1373
1289
  // src/console.ts
1374
- import { existsSync as existsSync6 } from "fs";
1375
-
1376
- // src/install/connections.ts
1377
- import { existsSync as existsSync5 } from "fs";
1378
-
1379
- // src/install/clients.ts
1380
- import { existsSync as existsSync3, readFileSync as readFileSync2, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from "fs";
1381
- import { homedir, platform } from "os";
1382
- import { basename as basename2, dirname, join, resolve } from "path";
1383
-
1384
- // src/install/toml.ts
1385
- var HEADER = /^\s*\[(?!\[)([^[\]]+)\]\s*$/;
1386
- function serverTables(lines) {
1387
- const tables = [];
1388
- let open;
1389
- const close = (at) => {
1390
- if (open !== void 0) {
1391
- tables.push({ name: open.name, start: open.start, end: at });
1392
- open = void 0;
1393
- }
1394
- };
1395
- lines.forEach((line2, index) => {
1396
- const header2 = HEADER.exec(line2)?.[1];
1397
- if (header2 === void 0) {
1398
- return;
1399
- }
1400
- const parts = header2.split(".");
1401
- if (parts[0] === "mcp_servers" && parts.length === 2 && parts[1] !== void 0) {
1402
- close(index);
1403
- open = { name: unquote(parts[1]), start: index };
1404
- return;
1405
- }
1406
- close(index);
1407
- });
1408
- close(lines.length);
1409
- return tables;
1410
- }
1411
- function unquote(text) {
1412
- const trimmed = text.trim();
1413
- if (/^'.*'$/s.test(trimmed)) {
1414
- return trimmed.slice(1, -1);
1415
- }
1416
- if (!/^".*"$/s.test(trimmed)) {
1417
- return trimmed;
1418
- }
1419
- return trimmed.slice(1, -1).replace(/\\(["\\])/g, "$1");
1420
- }
1421
- function readKey(lines, table, key) {
1422
- const pattern = new RegExp(`^\\s*${key}\\s*=\\s*(.*)$`);
1423
- for (let index = table.start + 1; index < table.end; index += 1) {
1424
- const line2 = lines[index];
1425
- if (line2 === void 0 || HEADER.test(line2)) {
1426
- break;
1427
- }
1428
- const value = pattern.exec(line2)?.[1];
1429
- if (value !== void 0) {
1430
- return value.trim();
1431
- }
1432
- }
1433
- return void 0;
1434
- }
1435
- function splitItems(inner) {
1436
- const items = [];
1437
- let current = "";
1438
- let quote3;
1439
- let escaped = false;
1440
- for (const character of inner) {
1441
- if (escaped) {
1442
- current += character;
1443
- escaped = false;
1444
- continue;
1445
- }
1446
- if (character === "\\" && quote3 === '"') {
1447
- current += character;
1448
- escaped = true;
1449
- continue;
1450
- }
1451
- if (quote3 === void 0 && (character === '"' || character === "'")) {
1452
- quote3 = character;
1453
- current += character;
1454
- continue;
1455
- }
1456
- if (character === quote3) {
1457
- quote3 = void 0;
1458
- current += character;
1459
- continue;
1460
- }
1461
- if (character === "," && quote3 === void 0) {
1462
- items.push(current);
1463
- current = "";
1464
- continue;
1465
- }
1466
- current += character;
1467
- }
1468
- items.push(current);
1469
- return items;
1470
- }
1471
- function parseArray(value) {
1472
- if (value === void 0 || !value.startsWith("[")) {
1473
- return void 0;
1474
- }
1475
- if (!value.endsWith("]")) {
1476
- return void 0;
1477
- }
1478
- const inner = value.slice(1, -1).trim();
1479
- if (inner === "") {
1480
- return [];
1481
- }
1482
- return splitItems(inner).map((item) => item.trim()).filter((item, index, all) => item !== "" || index !== all.length - 1).map(unquote);
1483
- }
1484
- function readServers(text) {
1485
- const lines = text.split("\n");
1486
- const servers = {};
1487
- for (const table of serverTables(lines)) {
1488
- const command = readKey(lines, table, "command");
1489
- const entry = {};
1490
- if (command !== void 0) {
1491
- entry["command"] = unquote(command);
1492
- }
1493
- const args = parseArray(readKey(lines, table, "args"));
1494
- if (args !== void 0) {
1495
- entry["args"] = args;
1496
- }
1497
- const url = readKey(lines, table, "url");
1498
- if (url !== void 0) {
1499
- entry["url"] = unquote(url);
1500
- }
1501
- const enabled = readKey(lines, table, "enabled");
1502
- if (enabled !== void 0) {
1503
- entry["enabled"] = enabled.trim() === "true";
1504
- }
1505
- servers[table.name] = entry;
1506
- }
1507
- return servers;
1508
- }
1509
- var quote2 = (text) => `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
1510
- function writeServers(text, servers) {
1511
- const lines = text.split("\n");
1512
- const current = readServers(text);
1513
- for (const table of serverTables(lines).reverse()) {
1514
- const wanted = servers[table.name];
1515
- if (wanted === void 0 || wanted.command === void 0) {
1516
- continue;
1517
- }
1518
- const now = current[table.name];
1519
- if (now?.command === wanted.command && JSON.stringify(now.args ?? []) === JSON.stringify(wanted.args ?? [])) {
1520
- continue;
1521
- }
1522
- setKey(lines, table, "command", quote2(wanted.command));
1523
- setKey(lines, table, "args", `[${(wanted.args ?? []).map(quote2).join(", ")}]`);
1524
- }
1525
- return lines.join("\n");
1526
- }
1527
- function setKey(lines, table, key, value) {
1528
- const pattern = new RegExp(`^(\\s*)${key}\\s*=`);
1529
- for (let index = table.start + 1; index < table.end; index += 1) {
1530
- const line2 = lines[index];
1531
- if (line2 === void 0 || HEADER.test(line2)) {
1532
- break;
1533
- }
1534
- const indent = pattern.exec(line2)?.[1];
1535
- if (indent !== void 0) {
1536
- lines[index] = `${indent}${key} = ${value}`;
1537
- return;
1538
- }
1539
- }
1540
- lines.splice(table.start + 1, 0, `${key} = ${value}`);
1541
- }
1542
-
1543
- // src/install/clients.ts
1544
- var LABELS = {
1545
- "claude-code": "Claude Code",
1546
- "claude-desktop": "Claude Desktop",
1547
- cursor: "Cursor",
1548
- codex: "Codex"
1549
- };
1550
- var CLIENT_IDS = Object.keys(LABELS).filter(
1551
- (name) => name in LABELS
1552
- );
1553
- function isClientId(value) {
1554
- return CLIENT_IDS.some((known) => known === value);
1555
- }
1556
- function claudeDesktopPath() {
1557
- const home = homedir();
1558
- switch (platform()) {
1559
- case "darwin":
1560
- return join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
1561
- case "win32":
1562
- return join(process.env["APPDATA"] ?? join(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
1563
- default:
1564
- return join(process.env["XDG_CONFIG_HOME"] ?? join(home, ".config"), "Claude", "claude_desktop_config.json");
1565
- }
1566
- }
1567
- function discover(cwd) {
1568
- const home = homedir();
1569
- const sites = [];
1570
- const claudeCode = join(home, ".claude.json");
1571
- if (existsSync3(claudeCode)) {
1572
- const document = readJson(claudeCode);
1573
- const projects = document?.["projects"];
1574
- const here = resolve(cwd);
1575
- if (isRecord2(projects) && Object.prototype.hasOwnProperty.call(projects, here)) {
1576
- sites.push({
1577
- client: "claude-code",
1578
- label: LABELS["claude-code"],
1579
- format: "json",
1580
- path: claudeCode,
1581
- scope: `project ${here}`,
1582
- at: ["projects", here, "mcpServers"]
1583
- });
1584
- }
1585
- sites.push({
1586
- client: "claude-code",
1587
- label: LABELS["claude-code"],
1588
- format: "json",
1589
- path: claudeCode,
1590
- scope: "global",
1591
- at: ["mcpServers"]
1592
- });
1593
- }
1594
- const projectFile = join(resolve(cwd), ".mcp.json");
1595
- if (existsSync3(projectFile)) {
1596
- sites.push({
1597
- client: "claude-code",
1598
- label: LABELS["claude-code"],
1599
- format: "json",
1600
- path: projectFile,
1601
- scope: "project file",
1602
- at: ["mcpServers"]
1603
- });
1604
- }
1605
- const desktop = claudeDesktopPath();
1606
- if (existsSync3(desktop)) {
1607
- sites.push({
1608
- client: "claude-desktop",
1609
- label: LABELS["claude-desktop"],
1610
- format: "json",
1611
- path: desktop,
1612
- scope: "global",
1613
- at: ["mcpServers"]
1614
- });
1615
- }
1616
- const codex = join(process.env["CODEX_HOME"] ?? join(home, ".codex"), "config.toml");
1617
- if (existsSync3(codex)) {
1618
- sites.push({
1619
- client: "codex",
1620
- label: LABELS.codex,
1621
- format: "toml",
1622
- path: codex,
1623
- scope: "global",
1624
- at: ["mcp_servers"]
1625
- });
1626
- }
1627
- for (const [path, scope] of [
1628
- [join(resolve(cwd), ".cursor", "mcp.json"), "project"],
1629
- [join(home, ".cursor", "mcp.json"), "global"]
1630
- ]) {
1631
- if (existsSync3(path)) {
1632
- sites.push({ client: "cursor", label: LABELS.cursor, format: "json", path, scope, at: ["mcpServers"] });
1633
- }
1634
- }
1635
- return sites;
1636
- }
1637
- function isRecord2(value) {
1638
- return typeof value === "object" && value !== null && !Array.isArray(value);
1639
- }
1640
- function readJson(path) {
1641
- try {
1642
- const parsed = JSON.parse(readFileSync2(path, "utf8"));
1643
- return isRecord2(parsed) ? parsed : void 0;
1644
- } catch {
1645
- return void 0;
1646
- }
1647
- }
1648
- var ConfigError = class extends Error {
1649
- };
1650
- function readDocument(site) {
1651
- let text;
1652
- try {
1653
- text = readFileSync2(site.path, "utf8");
1654
- } catch (error) {
1655
- throw new ConfigError(`cannot read ${site.path}: ${error instanceof Error ? error.message : String(error)}`);
1656
- }
1657
- let parsed;
1658
- try {
1659
- parsed = JSON.parse(text);
1660
- } catch (error) {
1661
- throw new ConfigError(
1662
- `${site.path} is not valid JSON (${error instanceof Error ? error.message : String(error)}). Fix it or move it aside; synartesis will not rewrite a file it cannot read.`
1663
- );
1664
- }
1665
- if (!isRecord2(parsed)) {
1666
- throw new ConfigError(`${site.path} is not a JSON object, so it has no server list to change`);
1667
- }
1668
- return parsed;
1669
- }
1670
- function readServers2(document, at) {
1671
- let node = document;
1672
- for (const key of at) {
1673
- if (!isRecord2(node)) {
1674
- return {};
1675
- }
1676
- node = node[key];
1677
- }
1678
- if (!isRecord2(node)) {
1679
- return {};
1680
- }
1681
- const servers = {};
1682
- for (const [name, entry] of Object.entries(node)) {
1683
- if (isRecord2(entry)) {
1684
- servers[name] = entry;
1685
- }
1686
- }
1687
- return servers;
1688
- }
1689
- function withServers(document, at, servers) {
1690
- const head = at[0];
1691
- if (head === void 0) {
1692
- throw new ConfigError("no path to the server list");
1693
- }
1694
- const rest = at.slice(1);
1695
- const below = document[head];
1696
- const child = rest.length === 0 ? servers : withServers(isRecord2(below) ? below : {}, rest, servers);
1697
- return { ...document, [head]: child };
1698
- }
1699
- function indentOf(path) {
1700
- try {
1701
- const line2 = /\n([ \t]+)"/.exec(readFileSync2(path, "utf8"));
1702
- const found = line2?.[1];
1703
- if (found === void 0) {
1704
- return 2;
1705
- }
1706
- return found.startsWith(" ") ? " " : found.length;
1707
- } catch {
1708
- return 2;
1709
- }
1710
- }
1711
- function backupPathFor(path) {
1712
- return `${path}.synartesis-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
1713
- }
1714
- var KEEP_BACKUPS = 5;
1715
- function pruneBackups(path) {
1716
- try {
1717
- const dir = dirname(path);
1718
- const prefix = `${basename2(path)}.synartesis-backup-`;
1719
- const ours2 = readdirSync(dir).filter((name) => name.startsWith(prefix)).sort();
1720
- for (const name of ours2.slice(0, Math.max(0, ours2.length - KEEP_BACKUPS))) {
1721
- rmSync(join(dir, name), { force: true });
1722
- }
1723
- } catch {
1724
- }
1725
- }
1726
- function writeDocument(site, document) {
1727
- return writeText(site, `${JSON.stringify(document, void 0, indentOf(site.path))}
1728
- `);
1729
- }
1730
- function writeText(site, text) {
1731
- const backup = backupPathFor(site.path);
1732
- const original = readFileSync2(site.path);
1733
- writeFileSync(backup, original);
1734
- pruneBackups(site.path);
1735
- const temporary = join(dirname(site.path), `.synartesis-write-${String(process.pid)}.tmp`);
1736
- try {
1737
- writeFileSync(temporary, text);
1738
- renameSync(temporary, site.path);
1739
- } catch (error) {
1740
- try {
1741
- unlinkSync(temporary);
1742
- } catch {
1743
- }
1744
- throw new ConfigError(
1745
- `could not write ${site.path}: ${error instanceof Error ? error.message : String(error)}. The original is untouched, and a copy is at ${backup}.`
1746
- );
1747
- }
1748
- return backup;
1749
- }
1750
- function serversAt(site) {
1751
- if (site.format === "toml") {
1752
- try {
1753
- return readServers(readFileSync2(site.path, "utf8"));
1754
- } catch (error) {
1755
- throw new ConfigError(
1756
- `cannot read ${site.path}: ${error instanceof Error ? error.message : String(error)}`
1757
- );
1758
- }
1759
- }
1760
- return readServers2(readDocument(site), site.at);
1761
- }
1762
- function saveServers(site, servers) {
1763
- if (site.format === "toml") {
1764
- const text = readFileSync2(site.path, "utf8");
1765
- return writeText(site, writeServers(text, servers));
1766
- }
1767
- return writeDocument(site, withServers(readDocument(site), site.at, servers));
1768
- }
1769
-
1770
- // src/install/install.ts
1771
- import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
1772
- import { dirname as dirname2, resolve as resolve2 } from "path";
1773
- function recordPathFor(manifestPath) {
1774
- return resolve2(dirname2(manifestPath), "installed.json");
1775
- }
1776
- var EMPTY = { version: 1, wrapped: {} };
1777
- function isRecord3(value) {
1778
- return typeof value === "object" && value !== null && !Array.isArray(value);
1779
- }
1780
- function asRecord(value) {
1781
- if (!isRecord3(value)) {
1782
- return void 0;
1783
- }
1784
- const wrapped2 = value["wrapped"];
1785
- if (!isRecord3(wrapped2)) {
1786
- return void 0;
1787
- }
1788
- const kept = {};
1789
- for (const [key, entry] of Object.entries(wrapped2)) {
1790
- if (!isRecord3(entry)) {
1791
- continue;
1792
- }
1793
- const original = entry["original"];
1794
- const at = entry["at"];
1795
- if (!isRecord3(original)) {
1796
- continue;
1797
- }
1798
- if (!Array.isArray(at) || !at.every((step) => typeof step === "string")) {
1799
- continue;
1800
- }
1801
- kept[key] = { original, at };
1802
- }
1803
- return { version: 1, wrapped: kept };
1804
- }
1805
- function keyFor(site, server) {
1806
- return [site.path, site.scope, server].join("");
1807
- }
1808
- function readRecord(manifestPath) {
1809
- const path = recordPathFor(manifestPath);
1810
- if (!existsSync4(path)) {
1811
- return EMPTY;
1812
- }
1813
- try {
1814
- const parsed = JSON.parse(readFileSync3(path, "utf8"));
1815
- const record = asRecord(parsed);
1816
- if (record !== void 0) {
1817
- return record;
1818
- }
1819
- } catch {
1820
- }
1821
- return EMPTY;
1822
- }
1823
- function writeRecord(manifestPath, record) {
1824
- mkdirSync(dirname2(recordPathFor(manifestPath)), { recursive: true, mode: 448 });
1825
- writeFileSync2(recordPathFor(manifestPath), `${JSON.stringify(record, void 0, 2)}
1826
- `);
1827
- }
1828
- function proxyEntry(manifestPath, server, original, invoker) {
1829
- const command = { command: invoker.command, args: [...invoker.args] };
1830
- return {
1831
- ...command,
1832
- args: [...command.args, "--manifest", resolve2(manifestPath), "--server", server],
1833
- // The agent's environment, not ours: the upstream is started by the proxy
1834
- // from the manifest, but a client that set `env` here meant it for the
1835
- // server, and the manifest reads `${VAR}` out of exactly this environment.
1836
- ...original.env === void 0 ? {} : { env: original.env },
1837
- ...original.cwd === void 0 ? {} : { cwd: original.cwd }
1838
- };
1839
- }
1840
- function isWrapped(entry) {
1841
- const args = entry.args ?? [];
1842
- return args.includes("proxy") && (entry.command === "synartesis" || entry.command === "synartesis-proxy" || args.includes("synartesis") || args.some((arg) => arg.endsWith("dist/cli.js") || arg.endsWith("dist/proxy.js")));
1843
- }
1844
- function invokerFor(ourVersion, cliPath) {
1845
- if (pathBinaryMatches(ourVersion)) {
1846
- return { command: "synartesis", args: ["proxy"] };
1847
- }
1848
- return {
1849
- command: process.execPath,
1850
- args: [cliPath, "proxy"],
1851
- note: "the synartesis on your PATH is a different build, so the entries name this one directly"
1852
- };
1853
- }
1854
- async function planInstall(sites, manifestPath, invoker) {
1855
- let yaml = existsSync4(manifestPath) ? readFileSync3(manifestPath, "utf8") : void 0;
1856
- const plans = [];
1857
- const claimed = new Set(
1858
- yaml === void 0 ? [] : Object.keys(parseManifest(yaml, manifestPath).servers)
1859
- );
1860
- for (const site of sites) {
1861
- const servers = serversAt(site);
1862
- const planned = [];
1863
- const skipped = [];
1864
- for (const [name, entry] of Object.entries(servers)) {
1865
- if (isWrapped(entry)) {
1866
- skipped.push({ name, why: "already covered" });
1867
- continue;
1868
- }
1869
- if (entry.command === void 0) {
1870
- skipped.push({ name, why: entry.url === void 0 ? "no command to start" : "remote (http); stdio only today" });
1871
- continue;
1872
- }
1873
- if (entry.enabled === false) {
1874
- skipped.push({ name, why: "switched off in the config" });
1875
- continue;
1876
- }
1877
- const key = claimed.has(name) ? `${name}-${site.client}` : name;
1878
- if (claimed.has(key)) {
1879
- skipped.push({ name, why: `already in the policy as ${key}` });
1880
- continue;
1881
- }
1882
- let draft;
1883
- try {
1884
- draft = await draftManifest({
1885
- name: key,
1886
- command: entry.command,
1887
- args: [...entry.args ?? []],
1888
- ...yaml === void 0 ? {} : { existing: yaml }
1889
- });
1890
- } catch (error) {
1891
- skipped.push({
1892
- name,
1893
- why: `will not start: ${(error instanceof Error ? error.message : String(error)).slice(0, 60)}`
1894
- });
1895
- continue;
1896
- }
1897
- yaml = draft.yaml;
1898
- claimed.add(key);
1899
- planned.push({
1900
- name,
1901
- original: entry,
1902
- wrapped: proxyEntry(manifestPath, key, entry, invoker),
1903
- ...draft.adopted === void 0 ? {} : {
1904
- adopted: draft.adopted.server,
1905
- tools: draft.adopted.tools,
1906
- ...draft.adopted.provenance === void 0 ? {} : { provenance: draft.adopted.provenance }
1907
- }
1908
- });
1909
- }
1910
- plans.push({ site, servers: planned, skipped });
1911
- }
1912
- return { plans, yaml: yaml ?? "" };
1913
- }
1914
- function applyInstall(plans, manifestPath, yaml) {
1915
- if (!plans.some((plan) => plan.servers.length > 0)) {
1916
- return [];
1917
- }
1918
- parseManifest(yaml, manifestPath);
1919
- mkdirSync(dirname2(resolve2(manifestPath)), { recursive: true, mode: 448 });
1920
- writeFileSync2(manifestPath, yaml);
1921
- const record = readRecord(manifestPath);
1922
- const wrapped2 = { ...record.wrapped };
1923
- const applied = [];
1924
- for (const plan of plans) {
1925
- if (plan.servers.length === 0) {
1926
- continue;
1927
- }
1928
- const servers = { ...serversAt(plan.site) };
1929
- for (const server of plan.servers) {
1930
- servers[server.name] = server.wrapped;
1931
- wrapped2[keyFor(plan.site, server.name)] = { original: server.original, at: plan.site.at };
1932
- }
1933
- writeRecord(manifestPath, { version: 1, wrapped: wrapped2 });
1934
- const backup = saveServers(plan.site, servers);
1935
- applied.push({ site: plan.site, backup, servers: plan.servers.map((server) => server.name) });
1936
- }
1937
- return applied;
1938
- }
1939
- function applyUninstall(sites, manifestPath) {
1940
- const record = readRecord(manifestPath);
1941
- const restoredKeys = /* @__PURE__ */ new Set();
1942
- const restored = [];
1943
- for (const site of sites) {
1944
- const servers = { ...serversAt(site) };
1945
- const put = [];
1946
- const unknown = [];
1947
- for (const [name, entry] of Object.entries(servers)) {
1948
- if (!isWrapped(entry)) {
1949
- continue;
1950
- }
1951
- const known = record.wrapped[keyFor(site, name)];
1952
- if (known === void 0) {
1953
- unknown.push(name);
1954
- continue;
1955
- }
1956
- servers[name] = known.original;
1957
- put.push(name);
1958
- restoredKeys.add(keyFor(site, name));
1959
- }
1960
- if (put.length === 0 && unknown.length === 0) {
1961
- continue;
1962
- }
1963
- const backup = put.length === 0 ? "" : saveServers(site, servers);
1964
- restored.push({ site, backup, servers: put, unknown });
1965
- }
1966
- const remaining = Object.fromEntries(
1967
- Object.entries(record.wrapped).filter(([key]) => !restoredKeys.has(key))
1968
- );
1969
- writeRecord(manifestPath, { version: 1, wrapped: remaining });
1970
- return restored;
1971
- }
1290
+ import { existsSync as existsSync3 } from "fs";
1972
1291
 
1973
1292
  // src/install/connections.ts
1293
+ import { existsSync as existsSync2 } from "fs";
1974
1294
  var ACTIVE_WITHIN_MS = 2 * 60 * 1e3;
1975
1295
  function lastSeenByServer(journal) {
1976
1296
  return journal.lastSeenPerServer();
@@ -1979,7 +1299,7 @@ function commandMissing(command) {
1979
1299
  if (command === void 0) {
1980
1300
  return false;
1981
1301
  }
1982
- return (command.includes("/") || command.includes("\\")) && !existsSync5(command);
1302
+ return (command.includes("/") || command.includes("\\")) && !existsSync2(command);
1983
1303
  }
1984
1304
  function scan(journal, cwd) {
1985
1305
  const seen = journal === void 0 ? /* @__PURE__ */ new Map() : lastSeenByServer(journal);
@@ -2000,7 +1320,9 @@ function scan(journal, cwd) {
2000
1320
  }
2001
1321
  const connections = Object.entries(servers).map(([server, entry]) => {
2002
1322
  const covered = isWrapped(entry);
2003
- const lastSeen = seen.get(server);
1323
+ const args = entry.args ?? [];
1324
+ const named = covered && args.includes("--server") ? args[args.indexOf("--server") + 1] : void 0;
1325
+ const lastSeen = seen.get(named ?? server);
2004
1326
  return {
2005
1327
  client: site.client,
2006
1328
  scope: site.scope,
@@ -2036,6 +1358,7 @@ function needsConnecting(groups) {
2036
1358
  }
2037
1359
 
2038
1360
  // src/console.ts
1361
+ var HOUR_MS2 = 60 * 60 * 1e3;
2039
1362
  var FRAMES2 = [
2040
1363
  "\u280B",
2041
1364
  "\u2819",
@@ -2208,7 +1531,7 @@ function connectionsView(screen, options) {
2208
1531
  return [
2209
1532
  ` ${style.quiet("No MCP client config was found on this machine.")}`,
2210
1533
  "",
2211
- ` ${style.quiet("Looked for Claude Code, Claude Desktop, Cursor and Codex.")}`
1534
+ ` ${style.quiet(LOOKED_FOR)}`
2212
1535
  ];
2213
1536
  }
2214
1537
  const rows = connectionRows(screen);
@@ -2263,7 +1586,7 @@ function footer(screen, options) {
2263
1586
  const what = screen.confirmingForce ? `undo ${screen.confirmingLabel ?? "this session"} anyway, losing that change?` : `undo ${screen.confirmingLabel ?? "this session"}?`;
2264
1587
  return ["", ` ${style.accent(what)} ${keyHint2("y", "yes")} ${keyHint2("n", "no")}`];
2265
1588
  }
2266
- 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("d", "deny"), keyHint2("j/k", "move"), keyHint2("r", "runs")] : screen.mode === "run" ? [
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" ? [
2267
1590
  keyHint2("l", "check now"),
2268
1591
  keyHint2("p", "preview undo"),
2269
1592
  keyHint2("u", "undo"),
@@ -2314,7 +1637,7 @@ async function* terminalKeys2() {
2314
1637
  async function openConsole(options) {
2315
1638
  let journal;
2316
1639
  const open = () => {
2317
- if (journal === void 0 && existsSync6(options.journalPath)) {
1640
+ if (journal === void 0 && existsSync3(options.journalPath)) {
2318
1641
  journal = openJournal(options.journalPath, { mustExist: true });
2319
1642
  }
2320
1643
  return journal;
@@ -2401,7 +1724,7 @@ async function openConsole(options) {
2401
1724
  }
2402
1725
  return ["", ` ${style.quiet("nothing to undo in this session")}`];
2403
1726
  };
2404
- const decide = (approve) => {
1727
+ const decide = (approve, forAnHour = false) => {
2405
1728
  const ready = open();
2406
1729
  if (ready === void 0) {
2407
1730
  return;
@@ -2411,9 +1734,12 @@ async function openConsole(options) {
2411
1734
  if (action === void 0) {
2412
1735
  return;
2413
1736
  }
2414
- const changed = approve ? ready.approve(action.id, options.decideAs) : ready.deny(action.id, options.decideAs, "denied from the console");
1737
+ const changed = approve ? ready.approve(action.id, options.decideAs) : ready.denyByPerson(action.id, options.decideAs, "denied from the console");
1738
+ if (changed && approve && forAnHour) {
1739
+ ready.allow(action.server, action.tool, options.decideAs, new Date(Date.now() + HOUR_MS2).toISOString());
1740
+ }
2415
1741
  say(
2416
- !changed ? `${action.server}.${action.tool} was already settled` : approve ? `approved ${action.server}.${action.tool} ${DOT} now tell the agent to try again` : `denied ${action.server}.${action.tool} ${DOT} it will not go through`
1742
+ !changed ? `${action.server}.${action.tool} was already settled` : approve ? forAnHour ? `approved ${action.server}.${action.tool} ${DOT} not asked again for an hour ${DOT} tell the agent to try again` : `approved ${action.server}.${action.tool} ${DOT} now tell the agent to try again` : `denied ${action.server}.${action.tool} ${DOT} the agent is told if it asks again`
2417
1743
  );
2418
1744
  screen.cursor = 0;
2419
1745
  };
@@ -2566,6 +1892,11 @@ async function openConsole(options) {
2566
1892
  }
2567
1893
  return;
2568
1894
  }
1895
+ case "A":
1896
+ if (screen.mode === "gates") {
1897
+ decide(true, true);
1898
+ }
1899
+ return;
2569
1900
  case "a":
2570
1901
  if (screen.mode === "gates") {
2571
1902
  decide(true);
@@ -2690,7 +2021,7 @@ async function openConsole(options) {
2690
2021
  if (options.maxTicks !== void 0 && tick + 1 >= options.maxTicks) {
2691
2022
  break;
2692
2023
  }
2693
- await new Promise((resolve4) => setTimeout(resolve4, interval));
2024
+ await new Promise((resolve2) => setTimeout(resolve2, interval));
2694
2025
  }
2695
2026
  return 0;
2696
2027
  } finally {
@@ -2706,40 +2037,40 @@ async function openConsole(options) {
2706
2037
  await reader?.return?.(void 0);
2707
2038
  await reading;
2708
2039
  })(),
2709
- new Promise((resolve4) => setTimeout(resolve4, 50).unref())
2040
+ new Promise((resolve2) => setTimeout(resolve2, 50).unref())
2710
2041
  ]);
2711
2042
  journal?.close();
2712
2043
  }
2713
2044
  }
2714
2045
 
2715
2046
  // src/desktop.ts
2716
- import { existsSync as existsSync7 } from "fs";
2717
- import { homedir as homedir2 } from "os";
2718
- import { join as join2 } from "path";
2047
+ import { existsSync as existsSync4 } from "fs";
2048
+ import { homedir } from "os";
2049
+ import { join } from "path";
2719
2050
  var RELEASES = "https://github.com/ArhaanDev24/Synartesis/releases";
2720
2051
  function candidates(platform2 = process.platform) {
2721
- const home = homedir2();
2052
+ const home = homedir();
2722
2053
  if (platform2 === "darwin") {
2723
2054
  return [
2724
2055
  "/Applications/Synartesis.app",
2725
- join2(home, "Applications/Synartesis.app")
2056
+ join(home, "Applications/Synartesis.app")
2726
2057
  ];
2727
2058
  }
2728
2059
  if (platform2 === "win32") {
2729
- const local = process.env["LOCALAPPDATA"] ?? join2(home, "AppData/Local");
2060
+ const local = process.env["LOCALAPPDATA"] ?? join(home, "AppData/Local");
2730
2061
  return [
2731
- join2(local, "Programs/Synartesis/Synartesis.exe"),
2732
- join2(process.env["PROGRAMFILES"] ?? "C:/Program Files", "Synartesis/Synartesis.exe")
2062
+ join(local, "Programs/Synartesis/Synartesis.exe"),
2063
+ join(process.env["PROGRAMFILES"] ?? "C:/Program Files", "Synartesis/Synartesis.exe")
2733
2064
  ];
2734
2065
  }
2735
2066
  return [
2736
2067
  "/opt/Synartesis/synartesis-desktop",
2737
2068
  "/usr/bin/synartesis-desktop",
2738
- join2(home, ".local/bin/synartesis-desktop"),
2739
- join2(home, "Applications/Synartesis.AppImage")
2069
+ join(home, ".local/bin/synartesis-desktop"),
2070
+ join(home, "Applications/Synartesis.AppImage")
2740
2071
  ];
2741
2072
  }
2742
- function findDesktop(platform2 = process.platform, here = existsSync7) {
2073
+ function findDesktop(platform2 = process.platform, here = existsSync4) {
2743
2074
  for (const path of candidates(platform2)) {
2744
2075
  if (!here(path)) {
2745
2076
  continue;
@@ -2782,7 +2113,7 @@ var COMMANDS = `
2782
2113
  undo -- all in one place, with
2783
2114
  the arrow keys. Everything
2784
2115
  below can be done from it.
2785
- synartesis install [--client <name>] [--dry-run] [--print]
2116
+ synartesis install [--client <name>] [--remote] [--dry-run] [--print]
2786
2117
  synartesis uninstall [--client <name>]
2787
2118
  synartesis status
2788
2119
  synartesis init <server> -- <command> [args...] [--manifest <path>]
@@ -2801,14 +2132,19 @@ var COMMANDS = `
2801
2132
  synartesis watch [--by <name>] [--journal <path>]
2802
2133
  synartesis approve [actionId|--all] [--by <name>] [--journal <path>]
2803
2134
  synartesis deny [actionId|--all] [--by <name>] [--reason <text>] [--journal <path>]
2135
+ synartesis allow [<server.tool> --for <30m|2h> | --always | --stop]
2804
2136
  synartesis resolve [actionId] --applied|--failed [--by <name>] [--reason <text>]
2137
+ synartesis notify --test
2805
2138
  synartesis undo [runId] [--to <seq>] [--dry-run] [--replan] [--force [--yes]]
2806
2139
  [--manifest <path>] [--journal <path>]
2807
2140
 
2808
2141
  install is the short way in: it finds what Claude Code, Claude Desktop,
2809
- Cursor or Codex already list, writes a policy covering all of it -- using the ones that
2810
- ship where they fit -- and points each entry at the proxy. The original config
2142
+ Cursor, Codex, Gemini CLI, Copilot CLI, Antigravity and Devin Desktop (or
2143
+ Windsurf) already list, writes a policy covering all of it -- using the ones
2144
+ that ship where they fit -- and points each entry at the proxy. The original config
2811
2145
  is copied aside first, and uninstall puts it back. status says what is covered.
2146
+ A hosted server (a url rather than a command) is covered only with --remote,
2147
+ through mcp-remote, which opens your browser to sign in.
2812
2148
 
2813
2149
  desktop opens the window, if it is installed. It is a separate download --
2814
2150
  shipping it through npm would put a browser engine inside every install of
@@ -2840,7 +2176,8 @@ undo stops when somebody has changed the resource since, rather than writing
2840
2176
  over them. Three ways past that, and it prints all three: leave it, put the
2841
2177
  resource back as the run left it and --replan, or --force to overwrite.
2842
2178
 
2843
- --client claude-code, claude-desktop, cursor or codex; all by default
2179
+ --client claude-code, claude-desktop, cursor, codex, gemini-cli,
2180
+ copilot-cli, antigravity, devin or windsurf; all by default
2844
2181
  --print show the entries install would write, and write nothing
2845
2182
  --full show every argument, snapshot and inverse in full, nothing elided
2846
2183
  --live read each resource as it is now and say what has changed since
@@ -2853,6 +2190,8 @@ resource back as the run left it and --replan, or --force to overwrite.
2853
2190
  --once watch prints the current state and exits
2854
2191
  --json machine-readable output for list, show and gates
2855
2192
  --dry-run read current state and print the plan without changing anything
2193
+ --unattended approve with no terminal, from a script. Recorded as unattended,
2194
+ so it can be told apart from a yes a person typed
2856
2195
  --applied resolve: the call did land, though nothing recorded it
2857
2196
  --failed resolve: the call never landed
2858
2197
  --replan rebuild each undo from the current manifest, for a run recorded
@@ -2912,7 +2251,9 @@ var TAKES_VALUE = /* @__PURE__ */ new Set([
2912
2251
  "--by",
2913
2252
  "--reason",
2914
2253
  "--older-than",
2915
- "--client"
2254
+ "--client",
2255
+ "--for",
2256
+ "--confirm"
2916
2257
  ]);
2917
2258
  function positional(argv) {
2918
2259
  const values = [];
@@ -2929,23 +2270,44 @@ function positional(argv) {
2929
2270
  }
2930
2271
  return values;
2931
2272
  }
2273
+ async function startAsTheClientWould(manifestPath, name, spec, session) {
2274
+ const client = clientEnvFor(manifestPath, name);
2275
+ const source = client === void 0 ? { kind: "manifest" } : { kind: "client", env: client.env };
2276
+ const recorded = session?.journal.runServer(session.runId, name);
2277
+ if (session !== void 0 && recorded !== void 0) {
2278
+ const now = fingerprint2(
2279
+ session.journal.fingerprintKey(),
2280
+ upstreamEnv(name, spec, source),
2281
+ Object.keys(recorded.fingerprints)
2282
+ );
2283
+ const changed = differing(recorded.fingerprints, now);
2284
+ if (changed.length > 0) {
2285
+ const one = changed.length === 1;
2286
+ throw new ConfigError(
2287
+ `${changed.join(", ")} ${one ? "is" : "are"} not what this session's ${name} server was started with, so undoing through it now could act on a different store from the one the session wrote to. Set ${one ? "it" : "them"} back as ${one ? "it was" : "they were"} in ${client?.from ?? "the client config"} to undo this session.`
2288
+ );
2289
+ }
2290
+ }
2291
+ const cwd = recorded?.cwd ?? client?.cwd;
2292
+ return await connectUpstream(name, spec, {
2293
+ env: source,
2294
+ stderr: "capture",
2295
+ ...cwd === void 0 ? {} : { cwd }
2296
+ });
2297
+ }
2932
2298
  async function runPin(argv) {
2933
2299
  const path = findManifest(flag(argv, "--manifest"));
2934
2300
  const manifest = loadManifest(path);
2935
- const shapes = /* @__PURE__ */ new Map();
2301
+ let shapes = /* @__PURE__ */ new Map();
2936
2302
  const upstreams = [];
2937
2303
  try {
2938
- for (const [name, spec] of Object.entries(manifest.servers)) {
2939
- const upstream = await connectStdioUpstream({
2940
- name,
2941
- command: spec.command,
2942
- args: spec.args,
2943
- stderr: "capture",
2944
- ...spec.env === void 0 ? {} : { env: spec.env }
2945
- });
2946
- upstreams.push(upstream);
2947
- shapes.set(name, await toolShapes(upstream));
2948
- }
2304
+ upstreams.push(
2305
+ ...await startAll(
2306
+ Object.entries(manifest.servers),
2307
+ ([name, spec]) => startAsTheClientWould(path, name, spec)
2308
+ )
2309
+ );
2310
+ shapes = await listAll(upstreams);
2949
2311
  } finally {
2950
2312
  for (const upstream of upstreams) {
2951
2313
  await upstream.close();
@@ -2988,23 +2350,44 @@ async function runCheck(argv) {
2988
2350
  out(` ${style.accent(line2)}`);
2989
2351
  }
2990
2352
  }
2353
+ for (const [name, spec] of Object.entries(manifest.servers)) {
2354
+ for (const [header2, value] of Object.entries(spec.url === void 0 ? {} : spec.headers ?? {})) {
2355
+ if (!value.includes("${")) {
2356
+ out("");
2357
+ for (const line2 of wrapped(
2358
+ `${name}'s ${header2} header is written into the policy itself. If it is a token, move it to the client entry's env and write "\${NAME}" here instead.`,
2359
+ 74
2360
+ )) {
2361
+ out(` ${style.accent(line2)}`);
2362
+ }
2363
+ }
2364
+ }
2365
+ }
2991
2366
  const upstreams = [];
2992
2367
  const offered = /* @__PURE__ */ new Map();
2368
+ const trustedReads = /* @__PURE__ */ new Map();
2993
2369
  try {
2994
- for (const [name, spec] of Object.entries(manifest.servers)) {
2995
- upstreams.push(
2996
- await connectStdioUpstream({
2997
- name,
2998
- command: spec.command,
2999
- args: spec.args,
3000
- stderr: "capture",
3001
- ...spec.env === void 0 ? {} : { env: spec.env }
3002
- })
3003
- );
3004
- }
3005
- await verifyAgainstServers(upstreams, manifest);
2370
+ upstreams.push(
2371
+ ...await startAll(
2372
+ Object.entries(manifest.servers),
2373
+ ([name, spec]) => startAsTheClientWould(path, name, spec)
2374
+ )
2375
+ );
2376
+ const listed = await listAll(upstreams);
2377
+ await verifyAgainstServers(upstreams, manifest, listed);
2378
+ const resolver = createPolicyResolver(manifest);
3006
2379
  for (const upstream of upstreams) {
3007
- offered.set(upstream.name, (await toolShapes(upstream)).map((tool) => tool.name));
2380
+ const shapes = listed.get(upstream.name) ?? [];
2381
+ const trusted = shapes.filter(
2382
+ (tool) => tool.readOnly === true && trustsMarks(manifest, upstream.name) && !resolver.resolve(`${upstream.name}.${tool.name}`).matched
2383
+ ).map((tool) => tool.name);
2384
+ offered.set(
2385
+ upstream.name,
2386
+ shapes.map((tool) => tool.name).filter((tool) => !trusted.includes(tool))
2387
+ );
2388
+ if (trusted.length > 0) {
2389
+ trustedReads.set(upstream.name, trusted);
2390
+ }
3008
2391
  }
3009
2392
  } finally {
3010
2393
  for (const upstream of upstreams) {
@@ -3051,8 +2434,8 @@ async function runCheck(argv) {
3051
2434
  `${String(total)} tool${total === 1 ? "" : "s"} here ${total === 1 ? "has" : "have"} no policy, so ${total === 1 ? "it is" : "they are"} treated as`
3052
2435
  )}`
3053
2436
  );
3054
- out(` ${style.quiet("irreversible and held for a person the first time an agent calls")}`);
3055
- out(` ${style.quiet(`${total === 1 ? "it" : "one"}. Write a policy for any you would rather it got on with.`)}`);
2437
+ out(` ${style.quiet("irreversible and held for a person every time an agent calls")}`);
2438
+ out(` ${style.quiet(`${total === 1 ? "it" : "one"}. Write a policy, or allow it, for any you would rather it got on with.`)}`);
3056
2439
  out("");
3057
2440
  for (const entry of uncovered) {
3058
2441
  for (const line2 of wrapped(entry.tools.join(", "), 60)) {
@@ -3060,10 +2443,23 @@ async function runCheck(argv) {
3060
2443
  }
3061
2444
  }
3062
2445
  }
2446
+ if (trustedReads.size > 0) {
2447
+ out("");
2448
+ out(
2449
+ ` ${style.label("read as reads")} ${style.quiet("no rule, but the server marks these read-only, so they are not held:")}`
2450
+ );
2451
+ for (const [server, tools] of trustedReads) {
2452
+ for (const line2 of wrapped(tools.join(", "), 60)) {
2453
+ out(` ${style.quiet(server.padEnd(8))} ${line2}`);
2454
+ }
2455
+ }
2456
+ out(` ${style.quiet("trust_annotations: false on a server turns this off for it.")}`);
2457
+ }
3063
2458
  out("");
2459
+ const used = existsSync5(findJournal(flag(argv, "--journal"), path));
3064
2460
  hint(
3065
2461
  firstOf(
3066
- () => notPinned(manifest),
2462
+ () => used ? notPinned(manifest) : void 0,
3067
2463
  () => ({
3068
2464
  why: "this is sound; to see which clients it covers",
3069
2465
  run: "status",
@@ -3084,12 +2480,19 @@ async function runInstall(argv) {
3084
2480
  if (sites.length === 0) {
3085
2481
  out("");
3086
2482
  out(` ${style.quiet("No MCP client config was found on this machine.")}`);
3087
- out(` ${style.quiet("Looked for Claude Code, Claude Desktop, Cursor and Codex.")}`);
2483
+ out(` ${style.quiet(LOOKED_FOR)}`);
3088
2484
  out("");
3089
2485
  return 0;
3090
2486
  }
3091
- const invoker = invokerFor(version(), fileURLToPath2(import.meta.url));
3092
- const { plans, yaml } = await planInstall(sites, manifestPath, invoker);
2487
+ const invoker = invokerFor(version(), fileURLToPath(import.meta.url));
2488
+ const { plans, yaml } = await planInstall(sites, manifestPath, invoker, void 0, {
2489
+ remote: argv.includes("--remote"),
2490
+ start: !printOnly,
2491
+ starting: (name) => {
2492
+ process.stderr.write(` ${style.quiet(`starting ${name} to see what it offers...`)}
2493
+ `);
2494
+ }
2495
+ });
3093
2496
  const total = plans.reduce((sum, plan) => sum + plan.servers.length, 0);
3094
2497
  out("");
3095
2498
  if (invoker.note !== void 0 && total > 0) {
@@ -3102,16 +2505,38 @@ async function runInstall(argv) {
3102
2505
  out(` ${style.strong(plan.site.label)} ${style.quiet(plan.site.scope)}`);
3103
2506
  out(` ${style.quiet(plan.site.path)}`);
3104
2507
  for (const server of plan.servers) {
3105
- const note = server.adopted === void 0 ? style.accent("drafted, every tool held until you say how to undo it") : style.quiet(`the policy that ships for ${server.adopted} (${String(server.tools ?? 0)} tools)`);
2508
+ const note = server.again === true ? style.quiet("already in your policy from an earlier install; covered again") : server.unstarted === true ? style.quiet(
2509
+ server.adopted === void 0 ? "not started here; a policy is drafted when you install" : `not started here; the policy that ships for ${server.adopted} would be used`
2510
+ ) : server.adopted === void 0 ? style.accent("drafted, every tool held until you say how to undo it") : style.quiet(`the policy that ships for ${server.adopted} (${String(server.tools ?? 0)} tools)`);
3106
2511
  out(` ${style.strong(server.name.padEnd(18))} ${note}`);
3107
2512
  if (server.provenance === "documented") {
3108
2513
  out(
3109
2514
  ` ${" ".repeat(18)} ${style.accent("never run against the real server -- check it before trusting undo")}`
3110
2515
  );
3111
2516
  }
2517
+ if (server.direct !== void 0) {
2518
+ for (const line2 of [
2519
+ `hosted at ${server.direct}, reached directly with the headers your`,
2520
+ "client gave it. They now sit in this entry's env; the policy names them."
2521
+ ]) {
2522
+ out(` ${" ".repeat(18)} ${style.quiet(line2)}`);
2523
+ }
2524
+ }
2525
+ if (server.bridged !== void 0) {
2526
+ for (const line2 of [
2527
+ `hosted at ${server.bridged}, reached through mcp-remote,`,
2528
+ "which signs you in and keeps that sign-in itself; synartesis never sees it.",
2529
+ "Hosted tools are named differently from local packages, so no shipped",
2530
+ "policy applies: its writes are held until you write rules for them."
2531
+ ]) {
2532
+ out(` ${" ".repeat(18)} ${style.quiet(line2)}`);
2533
+ }
2534
+ }
3112
2535
  }
3113
2536
  for (const skip of plan.skipped) {
3114
- out(` ${style.quiet(skip.name.padEnd(18))} ${style.quiet(skip.why)}`);
2537
+ for (const [at, line2] of wrapped(skip.why, 58).entries()) {
2538
+ out(` ${style.quiet((at === 0 ? skip.name : "").padEnd(18))} ${style.quiet(line2)}`);
2539
+ }
3115
2540
  }
3116
2541
  if (plan.servers.length === 0 && plan.skipped.length === 0) {
3117
2542
  out(` ${style.quiet("no servers listed")}`);
@@ -3195,13 +2620,13 @@ async function runUninstall(argv) {
3195
2620
  }
3196
2621
  function openIfPresent(journalPath) {
3197
2622
  try {
3198
- return existsSync8(journalPath) ? openJournal(journalPath, { mustExist: true }) : void 0;
2623
+ return existsSync5(journalPath) ? openJournal(journalPath, { mustExist: true }) : void 0;
3199
2624
  } catch {
3200
2625
  return void 0;
3201
2626
  }
3202
2627
  }
3203
2628
  async function connectThese(targets, manifestPath) {
3204
- const invoker = invokerFor(version(), fileURLToPath2(import.meta.url));
2629
+ const invoker = invokerFor(version(), fileURLToPath(import.meta.url));
3205
2630
  const wanted = /* @__PURE__ */ new Map();
3206
2631
  const sites = /* @__PURE__ */ new Map();
3207
2632
  for (const target of targets) {
@@ -3209,14 +2634,15 @@ async function connectThese(targets, manifestPath) {
3209
2634
  sites.set(key, target.site);
3210
2635
  (wanted.get(key) ?? wanted.set(key, /* @__PURE__ */ new Set()).get(key))?.add(target.server);
3211
2636
  }
3212
- const { plans, yaml } = await planInstall([...sites.values()], manifestPath, invoker);
3213
- const narrowed = plans.map((plan) => ({
3214
- ...plan,
3215
- servers: plan.servers.filter(
3216
- (server) => wanted.get(`${plan.site.path}${plan.site.scope}`)?.has(server.name) === true
3217
- )
3218
- }));
3219
- const applied = applyInstall(narrowed, manifestPath, yaml);
2637
+ const { plans, yaml } = await planInstall(
2638
+ [...sites.values()],
2639
+ manifestPath,
2640
+ invoker,
2641
+ (site, name) => wanted.get(`${site.path}${site.scope}`)?.has(name) === true,
2642
+ // Picked by name from the list, which is the asking.
2643
+ { remote: true }
2644
+ );
2645
+ const applied = applyInstall(plans, manifestPath, yaml);
3220
2646
  const count = applied.reduce((sum, entry) => sum + entry.servers.length, 0);
3221
2647
  if (count === 0) {
3222
2648
  return "nothing was connected; see the reasons above";
@@ -3228,7 +2654,7 @@ function runStatus(argv) {
3228
2654
  const journalPath = findJournal(flag(argv, "--journal"), manifestPath);
3229
2655
  out("");
3230
2656
  out(
3231
- ` ${style.label("policy")} ${existsSync8(manifestPath) ? style.strong(manifestPath) : style.quiet(`${manifestPath} (none yet)`)}`
2657
+ ` ${style.label("policy")} ${existsSync5(manifestPath) ? style.strong(manifestPath) : style.quiet(`${manifestPath} (none yet)`)}`
3232
2658
  );
3233
2659
  out(
3234
2660
  ` ${style.label("journal")} ${bytesOf(journalPath) === void 0 ? style.quiet(`${journalPath} (none yet)`) : `${style.strong(journalPath)} ${style.quiet(sizeOf(journalPath))}`}`
@@ -3239,7 +2665,7 @@ function runStatus(argv) {
3239
2665
  const groups = scan(journal, process.cwd());
3240
2666
  if (groups.length === 0) {
3241
2667
  out(` ${style.quiet("No MCP client config found.")}`);
3242
- out(` ${style.quiet("Looked for Claude Code, Claude Desktop, Cursor and Codex.")}`);
2668
+ out(` ${style.quiet(LOOKED_FOR)}`);
3243
2669
  out("");
3244
2670
  return 0;
3245
2671
  }
@@ -3284,7 +2710,7 @@ async function runInit(argv) {
3284
2710
  }
3285
2711
  const path = findManifest(flag(argv, "--manifest"));
3286
2712
  const force = argv.includes("--force");
3287
- const present = existsSync8(path);
2713
+ const present = existsSync5(path);
3288
2714
  if (present && force) {
3289
2715
  throw new UsageError(
3290
2716
  `--force would discard ${path}. Delete it yourself if that is what you want; init will otherwise add to it.`
@@ -3294,11 +2720,11 @@ async function runInit(argv) {
3294
2720
  name,
3295
2721
  command,
3296
2722
  args: argv.slice(separator + 2),
3297
- ...present ? { existing: readFileSync4(path, "utf8") } : {}
2723
+ ...present ? { existing: readFileSync(path, "utf8") } : {}
3298
2724
  });
3299
2725
  parseManifest(draft.yaml, path);
3300
- mkdirSync2(dirname3(resolve3(path)), { recursive: true, mode: 448 });
3301
- writeFileSync3(path, draft.yaml);
2726
+ mkdirSync(dirname(resolve(path)), { recursive: true, mode: 448 });
2727
+ writeFileSync(path, draft.yaml);
3302
2728
  out("");
3303
2729
  out(` ${style.label(present ? "extended" : "wrote")} ${style.strong(path)}`);
3304
2730
  out(` ${rule(54)}`);
@@ -3313,7 +2739,7 @@ async function runInit(argv) {
3313
2739
  out(` ${style.quiet("Read it before you trust it, then point your MCP client at:")}`);
3314
2740
  }
3315
2741
  out("");
3316
- out(` ${style.accent(`${proxyCommand()} --manifest ${resolve3(path)}`)}`);
2742
+ out(` ${style.accent(`${proxyCommand()} --manifest ${resolve(path)}`)}`);
3317
2743
  out("");
3318
2744
  return 0;
3319
2745
  }
@@ -3459,8 +2885,36 @@ function runList(journal, asJson, journalPath) {
3459
2885
  ` ${"session".padEnd(width)} ${"started".padEnd(13)} ${"did".padEnd(26)} ${"state".padEnd(26)} agent`
3460
2886
  )
3461
2887
  );
2888
+ let quiet = [];
2889
+ const flushQuiet = () => {
2890
+ if (quiet.length === 1) {
2891
+ const [only] = quiet;
2892
+ if (only !== void 0) {
2893
+ const did = didWhat(journal, only, 0);
2894
+ out(
2895
+ ` ${style.strong(only.id.slice(0, width))} ${style.quiet(shortTime(only.startedAt).trimEnd().padEnd(13))} ${laid(did.what, 26)} ${laid(did.state, 26)} ${style.quiet(only.label ?? "-")}`
2896
+ );
2897
+ }
2898
+ } else if (quiet.length > 1) {
2899
+ const newest = quiet[0];
2900
+ const oldest = quiet[quiet.length - 1];
2901
+ if (newest !== void 0 && oldest !== void 0) {
2902
+ out(
2903
+ ` ${style.quiet(
2904
+ `${"".padEnd(width)} ${String(quiet.length)} sessions with nothing in them, ${shortTime(oldest.startedAt).trim()} to ${shortTime(newest.startedAt).trim()}`
2905
+ )}`
2906
+ );
2907
+ }
2908
+ }
2909
+ quiet = [];
2910
+ };
3462
2911
  for (const run of runs) {
3463
2912
  const actions = counted2(run.id);
2913
+ if (actions.actions === 0) {
2914
+ quiet.push(run);
2915
+ continue;
2916
+ }
2917
+ flushQuiet();
3464
2918
  const { unknown, waiting } = actions;
3465
2919
  const notes = [
3466
2920
  unknown === 0 ? "" : `${String(unknown)} of unknown outcome`,
@@ -3472,6 +2926,7 @@ function runList(journal, asJson, journalPath) {
3472
2926
  ` ${style.strong(run.id.slice(0, width))} ${style.quiet(shortTime(run.startedAt).trimEnd().padEnd(13))} ${laid(did.what, 26)} ${laid(did.state, 26)} ${style.quiet(run.label ?? "-")}${note}`
3473
2927
  );
3474
2928
  }
2929
+ flushQuiet();
3475
2930
  out("");
3476
2931
  if ((bytesOf(journalPath) ?? 0) > PRUNE_NAG_BYTES) {
3477
2932
  out(` ${style.quiet(`This journal is ${sizeOf(journalPath)}; synartesis prune reclaims what is old enough to lose.`)}`);
@@ -3486,12 +2941,15 @@ function runList(journal, asJson, journalPath) {
3486
2941
  async function runShow(argv, journal, asJson) {
3487
2942
  const full = argv.includes("--full");
3488
2943
  const runs = [...journal.listRuns()].reverse();
3489
- const run = pick(runs, positional(argv)[1], RUN, true);
2944
+ const given = positional(argv)[1];
2945
+ const tally2 = journal.tallyRuns();
2946
+ const run = (given === void 0 ? runs.find((one) => (tally2.get(one.id)?.actions ?? 0) > 0) : void 0) ?? pick(runs, given, RUN, true);
3490
2947
  const runId = run.id;
3491
2948
  const inspection = argv.includes("--live") && journal.getActions(runId).length > 0 ? await withUpstreams(
3492
2949
  findManifest(flag(argv, "--manifest")),
3493
2950
  async (router) => await inspect({ journal, router, runId }),
3494
- serversUsedBy(journal, runId)
2951
+ serversUsedBy(journal, runId),
2952
+ { journal, runId }
3495
2953
  ) : void 0;
3496
2954
  if (asJson) {
3497
2955
  out(
@@ -3514,7 +2972,7 @@ async function runShow(argv, journal, asJson) {
3514
2972
  out(` ${style.quiet("agent ")} ${run.label ?? "-"}`);
3515
2973
  out(` ${style.quiet("started")} ${fullTime(run.startedAt)} ${style.quiet(ago(run.startedAt))}`);
3516
2974
  out(
3517
- ` ${style.quiet("status ")} ${run.status}` + (run.endedAt === void 0 ? "" : style.quiet(` ended ${fullTime(run.endedAt)}`))
2975
+ ` ${style.quiet("status ")} ${RUN_STATUS[run.status]}` + (run.endedAt === void 0 ? "" : style.quiet(` ended ${fullTime(run.endedAt)}`))
3518
2976
  );
3519
2977
  const actions = journal.getActions(runId);
3520
2978
  if (actions.length === 0) {
@@ -3576,7 +3034,7 @@ async function runShow(argv, journal, asJson) {
3576
3034
  }
3577
3035
  out("");
3578
3036
  }
3579
- out(` ${summarise2(actions)}`);
3037
+ out(` ${summarise(actions)}`);
3580
3038
  if (inspection !== void 0) {
3581
3039
  out("");
3582
3040
  const spoiled = inspection.resources.some((found) => found.condition === "changed");
@@ -3629,14 +3087,20 @@ var CLASS_MARK = {
3629
3087
  unclassified: "?"
3630
3088
  };
3631
3089
  var BADGE_WIDTH = "irreversible".length + 2;
3090
+ var RUN_STATUS = {
3091
+ active: "still running",
3092
+ complete: "finished",
3093
+ rolled_back: "undone",
3094
+ partial: "partly undone"
3095
+ };
3632
3096
  function badgeOf(action, pad2) {
3633
3097
  const name = `${CLASS_MARK[action.class]} ${action.class}`;
3634
3098
  const plain = pad2 ? name.padEnd(BADGE_WIDTH) : name;
3635
3099
  return action.class === "irreversible" ? style.accent(plain) : style.quiet(plain);
3636
3100
  }
3637
3101
  function statusOf(action, pad2) {
3638
- const label = labelFor(action);
3639
- const text = pad2 ? label.padEnd(13) : label;
3102
+ const label = plainly(action).text;
3103
+ const text = pad2 ? label.padEnd(22) : label;
3640
3104
  if (wasRefused(action)) {
3641
3105
  return style.accent(text);
3642
3106
  }
@@ -3675,7 +3139,7 @@ function inverseArgs(inverse) {
3675
3139
  function truncate3(text, limit) {
3676
3140
  return text.length <= limit ? text : `${text.slice(0, limit - 3)}...`;
3677
3141
  }
3678
- function summarise2(actions) {
3142
+ function summarise(actions) {
3679
3143
  const counts = /* @__PURE__ */ new Map();
3680
3144
  for (const action of actions) {
3681
3145
  const label = labelFor(action);
@@ -3869,12 +3333,28 @@ function runGates(journal, asJson) {
3869
3333
  return 0;
3870
3334
  }
3871
3335
  function runDecision(argv, journal, approving) {
3336
+ const unattended = argv.includes("--unattended");
3337
+ if (approving && !process.stdin.isTTY && !unattended) {
3338
+ throw new UsageError(
3339
+ "approve needs a person at a terminal, so that an agent with a shell cannot approve its own calls. Run it in your terminal, or answer it in synartesis watch. For approving from a script, see synartesis --help.",
3340
+ false
3341
+ );
3342
+ }
3872
3343
  const waiting = journal.listGated();
3873
3344
  const given = positional(argv)[1];
3874
- const by = flag(argv, "--by") ?? process.env["USER"] ?? process.env["LOGNAME"] ?? "unknown";
3345
+ const named = flag(argv, "--by") ?? process.env["USER"] ?? process.env["LOGNAME"] ?? "unknown";
3346
+ const by = approving && unattended ? `${named} (unattended)` : named;
3875
3347
  const reason = flag(argv, "--reason") ?? "denied by operator";
3876
3348
  if (given !== void 0) {
3877
3349
  const settled2 = journal.getAction(given);
3350
+ if (approving && settled2?.status === "denied" && journal.reverseDenial(settled2.id, by)) {
3351
+ out(
3352
+ ` ${style.accent("approved")} ${style.strong(`${settled2.server}.${settled2.tool}`)} ${style.quiet(settled2.id)} ${style.quiet("(was denied)")}`
3353
+ );
3354
+ out("");
3355
+ hint({ why: "the agent can make that call again now, and it will go through" });
3356
+ return 0;
3357
+ }
3878
3358
  if (settled2 !== void 0 && settled2.status !== "gated") {
3879
3359
  process.stderr.write(
3880
3360
  `synartesis: ${given} is no longer awaiting approval (it is ${labelFor(settled2)})
@@ -3891,7 +3371,7 @@ function runDecision(argv, journal, approving) {
3891
3371
  let failed = 0;
3892
3372
  let settled = 0;
3893
3373
  for (const action of targets) {
3894
- const changed = approving ? journal.approve(action.id, by) : journal.deny(action.id, by, reason);
3374
+ const changed = approving ? journal.approve(action.id, by) : journal.denyByPerson(action.id, by, reason);
3895
3375
  if (!changed) {
3896
3376
  const now = journal.getAction(action.id);
3897
3377
  process.stderr.write(
@@ -3911,26 +3391,249 @@ function runDecision(argv, journal, approving) {
3911
3391
  hint(
3912
3392
  firstOf(
3913
3393
  () => heldCalls(journal),
3914
- () => approving ? { why: "the agent can make that call again now, and it will go through" } : { why: "the call was refused; the agent is told, and decides what to do next" }
3394
+ () => approving ? { why: "the agent can make that call again now, and it will go through" } : settled === 1 ? {
3395
+ why: "refused; if the agent asks again it is told you said no. Changed your mind?",
3396
+ run: `approve ${targets[0]?.id.slice(0, 8) ?? ""}`,
3397
+ needs: ["journal"]
3398
+ } : { why: "refused; if the agent asks again it is told you said no" }
3915
3399
  )
3916
3400
  );
3917
3401
  }
3918
3402
  return failed === 0 ? 0 : 1;
3919
3403
  }
3404
+ var ALLOW_MAX_MINUTES = 24 * 60;
3405
+ function allowFor(given) {
3406
+ const found = /^(\d+)(m|h)$/.exec(given.trim());
3407
+ const minutes = found === null ? NaN : Number(found[1]) * (found[2] === "h" ? 60 : 1);
3408
+ if (!Number.isFinite(minutes) || minutes < 1 || minutes > ALLOW_MAX_MINUTES) {
3409
+ throw new UsageError(
3410
+ `--for takes minutes or hours up to a day, like 30m or 2h, not ${given}. For good, use --always.`
3411
+ );
3412
+ }
3413
+ return minutes;
3414
+ }
3415
+ async function runAllow(argv, journalPath) {
3416
+ const given = positional(argv)[1];
3417
+ const forFlag = flag(argv, "--for");
3418
+ const always = argv.includes("--always");
3419
+ const stop = argv.includes("--stop");
3420
+ const now = /* @__PURE__ */ new Date();
3421
+ if (given === void 0) {
3422
+ if (forFlag !== void 0 || always || stop) {
3423
+ throw new UsageError("allow needs the tool, as server.tool -- for example crm.send_email");
3424
+ }
3425
+ const journal = openJournalOrExplain(journalPath);
3426
+ try {
3427
+ const current = journal.listAllowances(now.toISOString());
3428
+ out("");
3429
+ if (current.length === 0) {
3430
+ out(` ${style.quiet("Nothing is being let through without asking.")}`);
3431
+ }
3432
+ for (const one of current) {
3433
+ out(
3434
+ ` ${style.strong(`${one.server}.${one.tool}`)} ${style.quiet(`until ${shortTime(one.until, now)}, allowed by ${one.by}`)}`
3435
+ );
3436
+ }
3437
+ out("");
3438
+ return 0;
3439
+ } finally {
3440
+ journal.close();
3441
+ }
3442
+ }
3443
+ const named = splitQualified(given);
3444
+ if (named === void 0) {
3445
+ throw new UsageError(`${given} is not a tool name; write it as server.tool, for example crm.send_email`);
3446
+ }
3447
+ if ([forFlag !== void 0, always, stop].filter(Boolean).length !== 1) {
3448
+ throw new UsageError(
3449
+ `say how long: allow ${given} --for 1h, allow ${given} --always, or allow ${given} --stop`
3450
+ );
3451
+ }
3452
+ const who = flag(argv, "--by") ?? process.env["USER"] ?? process.env["LOGNAME"] ?? "unknown";
3453
+ if (stop) {
3454
+ const journal = openJournalOrExplain(journalPath);
3455
+ try {
3456
+ const stopped = journal.stopAllowance(named.server, named.tool, who, now.toISOString());
3457
+ out(
3458
+ stopped ? ` ${style.accent("stopped")} ${style.strong(given)} ${style.quiet("is held again from its next call")}` : ` ${style.quiet(`${given} was not being let through`)}`
3459
+ );
3460
+ if (stopped) {
3461
+ out(` ${style.quiet("A rule written with --always stays in the policy; edit it there.")}`);
3462
+ }
3463
+ return 0;
3464
+ } finally {
3465
+ journal.close();
3466
+ }
3467
+ }
3468
+ const unattended = argv.includes("--unattended");
3469
+ const interactive = process.stdin.isTTY;
3470
+ if (!interactive && !unattended) {
3471
+ throw new UsageError(
3472
+ "allow needs a person at a terminal, so that an agent with a shell cannot allow its own calls. Run it in your terminal. For allowing from a script, see synartesis --help.",
3473
+ false
3474
+ );
3475
+ }
3476
+ const by = unattended ? `${who} (unattended)` : who;
3477
+ const manifestPath = findManifest(flag(argv, "--manifest"));
3478
+ const manifest = loadManifest(manifestPath);
3479
+ const spec = manifest.servers[named.server];
3480
+ if (spec === void 0) {
3481
+ const near = didYouMean(named.server, Object.keys(manifest.servers));
3482
+ throw new UsageError(
3483
+ `${manifestPath} has no server called ${named.server}${near === void 0 ? "" : `; did you mean ${near}?`}`
3484
+ );
3485
+ }
3486
+ const { policy } = createPolicyResolver(manifest).resolve(given);
3487
+ if (forFlag !== void 0) {
3488
+ const minutes = allowFor(forFlag);
3489
+ const until = new Date(now.getTime() + minutes * 6e4).toISOString();
3490
+ const journal = openJournalOrExplain(journalPath);
3491
+ try {
3492
+ journal.allow(named.server, named.tool, by, until);
3493
+ } finally {
3494
+ journal.close();
3495
+ }
3496
+ out("");
3497
+ out(
3498
+ ` ${style.accent("allowed")} ${style.strong(given)} ${style.quiet(`until ${shortTime(until, now)} -- its calls go out without asking, and are still recorded`)}`
3499
+ );
3500
+ if (policy.gate !== "always" && policy.gate !== "on_write") {
3501
+ out(` ${style.quiet("(the policy was not holding it anyway)")}`);
3502
+ } else if (policy.class === "irreversible") {
3503
+ out(` ${style.quiet("These cannot be undone. Nothing will ask before each one goes out.")}`);
3504
+ }
3505
+ out(` ${style.quiet(`Takes effect on its next call. To end it sooner: synartesis allow ${given} --stop`)}`);
3506
+ out("");
3507
+ return 0;
3508
+ }
3509
+ const text = readFileSync(manifestPath, "utf8");
3510
+ const pins = manifest.pins?.[named.server];
3511
+ let pin;
3512
+ if (pins !== void 0 && pins[named.tool] === void 0) {
3513
+ const upstream = await startAsTheClientWould(manifestPath, named.server, spec);
3514
+ try {
3515
+ const shape = (await toolShapes(upstream)).find((tool) => tool.name === named.tool);
3516
+ if (shape === void 0) {
3517
+ throw new UsageError(`${named.server} has no tool called ${named.tool}`);
3518
+ }
3519
+ pin = fingerprint(shape.inputSchema);
3520
+ } finally {
3521
+ await upstream.close();
3522
+ }
3523
+ }
3524
+ let edit;
3525
+ try {
3526
+ edit = allowAlways({
3527
+ text,
3528
+ file: manifestPath,
3529
+ server: named.server,
3530
+ tool: named.tool,
3531
+ by,
3532
+ date: now.toISOString().slice(0, 10),
3533
+ ...pin === void 0 ? {} : { pin }
3534
+ });
3535
+ } catch (error) {
3536
+ if (error instanceof PolicyEditError) {
3537
+ process.stderr.write(`synartesis: ${error.message}
3538
+ `);
3539
+ return 1;
3540
+ }
3541
+ throw error;
3542
+ }
3543
+ if (edit.how === "already") {
3544
+ out(` ${style.quiet(`${given} is already let through by ${manifestPath}`)}`);
3545
+ return 0;
3546
+ }
3547
+ if (edit.policy.class === "irreversible") {
3548
+ const confirmed = unattended ? flag(argv, "--confirm") : await ask(` ${given} cannot be undone. Type its name to stop holding it for good: `);
3549
+ if (confirmed?.trim() !== given) {
3550
+ process.stderr.write(`synartesis: not confirmed, so ${manifestPath} was not changed
3551
+ `);
3552
+ return 1;
3553
+ }
3554
+ }
3555
+ if (readFileSync(manifestPath, "utf8") !== text) {
3556
+ process.stderr.write(`synartesis: ${manifestPath} changed while this was running; nothing was written
3557
+ `);
3558
+ return 1;
3559
+ }
3560
+ const beside = `${manifestPath}.${String(process.pid)}.tmp`;
3561
+ writeFileSync(beside, edit.text, { mode: statSync(manifestPath).mode });
3562
+ renameSync(beside, manifestPath);
3563
+ out("");
3564
+ out(
3565
+ ` ${style.accent("allowed")} ${style.strong(given)} ${style.quiet(`for good -- ${edit.how === "added" ? "a rule was added to" : "its rule was changed in"} ${manifestPath}`)}`
3566
+ );
3567
+ if (edit.policy.class === "irreversible") {
3568
+ out(` ${style.quiet("It still cannot be undone: each call is recorded, and none is held.")}`);
3569
+ }
3570
+ if (pin !== void 0) {
3571
+ out(` ${style.quiet("Pinned at the shape it has now, as the rest of the server is.")}`);
3572
+ }
3573
+ out(` ${style.quiet("Takes effect when your client next starts the server. Until then:")}`);
3574
+ out(` ${style.quiet(`synartesis allow ${given} --for 1h`)}`);
3575
+ out("");
3576
+ return 0;
3577
+ }
3578
+ async function ask(question) {
3579
+ const reader = createInterface({ input: process.stdin, output: process.stdout });
3580
+ try {
3581
+ return await reader.question(question);
3582
+ } finally {
3583
+ reader.close();
3584
+ }
3585
+ }
3586
+ function stepWords(kind, dryRun) {
3587
+ switch (kind) {
3588
+ case "revert":
3589
+ return dryRun ? "would put back" : "put back";
3590
+ case "skip":
3591
+ return "nothing to undo";
3592
+ case "already-reverted":
3593
+ return "already undone";
3594
+ case "permanent":
3595
+ return "cannot undo";
3596
+ case "kept":
3597
+ return "left alone";
3598
+ case "halt":
3599
+ return "stopped";
3600
+ }
3601
+ }
3602
+ function resultWords(status, dryRun) {
3603
+ switch (status) {
3604
+ case "rolled_back":
3605
+ return dryRun ? "would all be undone (nothing was written)" : "all undone";
3606
+ case "partial":
3607
+ return dryRun ? "would be partly undone (nothing was written)" : "partly undone";
3608
+ }
3609
+ }
3920
3610
  function report(result, alreadyForcing = false, as = "") {
3921
3611
  out("");
3922
3612
  out(` ${style.label(result.dryRun ? "dry run" : "undo")} ${style.strong(result.runId)}`);
3923
3613
  out(` ${rule(72)}`);
3924
3614
  out("");
3925
3615
  let separated = false;
3616
+ let reads = 0;
3617
+ const flushReads = () => {
3618
+ if (reads > 0) {
3619
+ out(` ${style.quiet(`${String(reads)} read${reads === 1 ? "" : "s"}, nothing to undo`)}`);
3620
+ reads = 0;
3621
+ }
3622
+ };
3926
3623
  for (const step of result.steps) {
3624
+ if (step.kind === "skip" && step.reason === "readonly") {
3625
+ reads += 1;
3626
+ continue;
3627
+ }
3628
+ flushReads();
3927
3629
  if (step.kind === "kept" && !separated) {
3928
3630
  separated = true;
3929
3631
  out("");
3930
3632
  out(` ${style.quiet("left alone")}`);
3931
3633
  }
3932
3634
  const unverified = step.kind === "revert" && !step.verified ? ` ${style.accent("[unverified]")}` : "";
3933
- const kind = step.kind === "halt" || step.kind === "permanent" ? style.accent(step.kind.padEnd(16)) : step.kind.padEnd(16);
3635
+ const said = stepWords(step.kind, result.dryRun);
3636
+ const kind = step.kind === "halt" || step.kind === "permanent" ? style.accent(said.padEnd(16)) : said.padEnd(16);
3934
3637
  out(
3935
3638
  ` ${style.quiet(String(step.seq).padStart(3))} ${kind} ${style.strong(`${step.server}.${step.tool}`)} ${style.quiet(step.reason)}${unverified}`
3936
3639
  );
@@ -3950,6 +3653,7 @@ function report(result, alreadyForcing = false, as = "") {
3950
3653
  );
3951
3654
  }
3952
3655
  }
3656
+ flushReads();
3953
3657
  if (result.halted !== void 0) {
3954
3658
  const halt = result.halted;
3955
3659
  out("");
@@ -3993,8 +3697,9 @@ function report(result, alreadyForcing = false, as = "") {
3993
3697
  );
3994
3698
  }
3995
3699
  out("");
3700
+ const outcome = resultWords(result.status, result.dryRun);
3996
3701
  out(
3997
- ` ${style.label("result")} ${result.status === "rolled_back" ? result.status : style.accent(result.status)}`
3702
+ ` ${style.label("result")} ${result.status === "rolled_back" ? outcome : style.accent(outcome)}`
3998
3703
  );
3999
3704
  out("");
4000
3705
  if (result.dryRun && result.status === "rolled_back") {
@@ -4006,29 +3711,16 @@ function report(result, alreadyForcing = false, as = "") {
4006
3711
  }
4007
3712
  return result.halted === void 0 && permanent.length === 0 ? 0 : 1;
4008
3713
  }
4009
- async function withUpstreams(manifestPath, use, only) {
3714
+ async function withUpstreams(manifestPath, use, only, session) {
4010
3715
  const manifest = loadManifest(manifestPath);
4011
- const upstreams = [];
4012
- const missing = [];
3716
+ const wanted = Object.entries(manifest.servers).filter(([name]) => only === void 0 || only.has(name));
3717
+ const { started, failed } = await startTogether(
3718
+ wanted,
3719
+ ([name, spec]) => startAsTheClientWould(manifestPath, name, spec, session)
3720
+ );
3721
+ const upstreams = [...started];
3722
+ const missing = failed.map(({ item: [name], error }) => `${name}: ${describe(error)}`);
4013
3723
  try {
4014
- for (const [name, spec] of Object.entries(manifest.servers)) {
4015
- if (only !== void 0 && !only.has(name)) {
4016
- continue;
4017
- }
4018
- try {
4019
- upstreams.push(
4020
- await connectStdioUpstream({
4021
- name,
4022
- command: spec.command,
4023
- args: spec.args,
4024
- stderr: "capture",
4025
- ...spec.env === void 0 ? {} : { env: spec.env }
4026
- })
4027
- );
4028
- } catch (error) {
4029
- missing.push(`${name}: ${describe(error)}`);
4030
- }
4031
- }
4032
3724
  if (upstreams.length === 0 && missing.length > 0) {
4033
3725
  throw new ManifestError(`no server could be started. ${missing.join("; ")}`);
4034
3726
  }
@@ -4077,7 +3769,8 @@ async function performUndo(manifestPath, journal, runId, options) {
4077
3769
  }),
4078
3770
  // A replan re-resolves inverses from the current policy, which may name a
4079
3771
  // server this run never used; everything else needs only what it touched.
4080
- options.replan === true ? void 0 : serversUsedBy(journal, runId)
3772
+ options.replan === true ? void 0 : serversUsedBy(journal, runId),
3773
+ { journal, runId }
4081
3774
  );
4082
3775
  }
4083
3776
  async function runUndo(argv, journal) {
@@ -4087,9 +3780,16 @@ async function runUndo(argv, journal) {
4087
3780
  throw new UsageError("--to needs a positive whole number", false);
4088
3781
  }
4089
3782
  const given = positional(argv)[1];
4090
- const chosen = pick([...journal.listRuns()].reverse(), given, RUN, true);
3783
+ const runs = [...journal.listRuns()].reverse();
3784
+ const standing3 = journal.standingPerRun();
3785
+ const hasWork = (id) => {
3786
+ const here = standing3.get(id);
3787
+ return here !== void 0 && (here.undoable > 0 || here.conflicted > 0);
3788
+ };
3789
+ const newestWithWork = given === void 0 ? runs.find((run) => hasWork(run.id)) : void 0;
3790
+ const chosen = newestWithWork ?? pick(runs, given, RUN, true);
4091
3791
  const runId = chosen.id;
4092
- if (journal.getRun(runId)?.status === "active" && !argv.includes("--yes") && !argv.includes("--dry-run")) {
3792
+ if (journal.getRun(runId)?.status === "active" && hasWork(runId) && !argv.includes("--yes") && !argv.includes("--dry-run")) {
4093
3793
  throw new UsageError(
4094
3794
  `${runId.slice(0, 8)} has not ended, so an agent may still be writing to it.
4095
3795
  See what an undo would do: ${cliCommand()} undo ${runId.slice(0, 8)} --dry-run
@@ -4105,7 +3805,9 @@ async function runUndo(argv, journal) {
4105
3805
  ).length;
4106
3806
  out("");
4107
3807
  out(
4108
- ` ${style.quiet("no session named, so the most recent:")} ${style.strong(runId.slice(0, 8))} ` + style.quiet(`${chosen.label ?? "an agent"}, ${shortTime(chosen.startedAt).trim()}`)
3808
+ ` ${style.quiet(
3809
+ newestWithWork === void 0 ? "no session named, so the most recent:" : "no session named, so the most recent with something to undo:"
3810
+ )} ${style.strong(runId.slice(0, 8))} ` + style.quiet(`${chosen.label ?? "an agent"}, ${shortTime(chosen.startedAt).trim()}`)
4109
3811
  );
4110
3812
  if (left === 0) {
4111
3813
  out("");
@@ -4155,7 +3857,8 @@ async function runUndo(argv, journal) {
4155
3857
  const over = (await withUpstreams(
4156
3858
  manifestPath,
4157
3859
  async (router) => await inspect({ journal, router, runId }),
4158
- serversUsedBy(journal, runId)
3860
+ serversUsedBy(journal, runId),
3861
+ { journal, runId }
4159
3862
  )).resources.filter(
4160
3863
  // Below --to nothing is undone, so a change down there is not something
4161
3864
  // this command would write over and must not stand in its way.
@@ -4217,7 +3920,9 @@ var KNOWN_COMMANDS = [
4217
3920
  "watch",
4218
3921
  "approve",
4219
3922
  "deny",
3923
+ "allow",
4220
3924
  "resolve",
3925
+ "notify",
4221
3926
  "undo",
4222
3927
  "help",
4223
3928
  "version"
@@ -4247,6 +3952,18 @@ var FLAGS = /* @__PURE__ */ new Set([
4247
3952
  "--force",
4248
3953
  "--yes",
4249
3954
  "--older-than",
3955
+ // install: cover hosted servers too, through mcp-remote.
3956
+ "--remote",
3957
+ // notify: send one to see whether they reach you.
3958
+ "--test",
3959
+ // approve without a terminal, from a script; recorded as unattended.
3960
+ "--unattended",
3961
+ // allow: for a while, for good, or no longer; and the typed confirmation
3962
+ // for a tool that cannot be undone, given without a terminal.
3963
+ "--for",
3964
+ "--always",
3965
+ "--stop",
3966
+ "--confirm",
4250
3967
  "--help",
4251
3968
  "-h",
4252
3969
  "--version",
@@ -4255,8 +3972,8 @@ var FLAGS = /* @__PURE__ */ new Set([
4255
3972
  ]);
4256
3973
  function version() {
4257
3974
  try {
4258
- const root = dirname3(fileURLToPath2(import.meta.url));
4259
- const parsed = JSON.parse(readFileSync4(join3(root, "..", "package.json"), "utf8"));
3975
+ const root = dirname(fileURLToPath(import.meta.url));
3976
+ const parsed = JSON.parse(readFileSync(join2(root, "..", "package.json"), "utf8"));
4260
3977
  const found = typeof parsed === "object" && parsed !== null ? parsed.version : void 0;
4261
3978
  return typeof found === "string" ? found : "unknown";
4262
3979
  } catch {
@@ -4287,7 +4004,7 @@ function rejectUnknownFlags(argv) {
4287
4004
  }
4288
4005
  }
4289
4006
  function openJournalOrExplain(journalPath) {
4290
- if (!existsSync8(journalPath)) {
4007
+ if (!existsSync5(journalPath)) {
4291
4008
  throw new UsageError(
4292
4009
  `nothing has been recorded yet: there is no journal at ${journalPath}. One appears the first time an agent calls a tool through synartesis proxy.`
4293
4010
  );
@@ -4317,8 +4034,8 @@ ${COMMANDS}`);
4317
4034
  }
4318
4035
  const givenJournal = flag(argv, "--journal");
4319
4036
  const givenManifest = flag(argv, "--manifest");
4320
- journalArg = givenJournal === void 0 ? "" : ` --journal ${resolve3(givenJournal)}`;
4321
- manifestArg = givenManifest === void 0 ? "" : ` --manifest ${resolve3(givenManifest)}`;
4037
+ journalArg = givenJournal === void 0 ? "" : ` --journal ${resolve(givenJournal)}`;
4038
+ manifestArg = givenManifest === void 0 ? "" : ` --manifest ${resolve(givenManifest)}`;
4322
4039
  if (command === void 0) {
4323
4040
  const manifestPath = findManifest(flag(argv, "--manifest"));
4324
4041
  const journalPath2 = findJournal(flag(argv, "--journal"), manifestPath);
@@ -4335,7 +4052,8 @@ ${COMMANDS}`);
4335
4052
  return await withUpstreams(
4336
4053
  manifestPath,
4337
4054
  async (router) => await inspect({ journal: journal2, router, runId }),
4338
- serversUsedBy(journal2, runId)
4055
+ serversUsedBy(journal2, runId),
4056
+ { journal: journal2, runId }
4339
4057
  );
4340
4058
  } finally {
4341
4059
  journal2.close();
@@ -4387,7 +4105,7 @@ ${COMMANDS}`);
4387
4105
  decideAs: flag(argv, "--by") ?? process.env["USER"] ?? process.env["LOGNAME"] ?? "unknown"
4388
4106
  });
4389
4107
  }
4390
- if (!existsSync8(journalPath) && (command === "list" || command === "show" || command === "gates")) {
4108
+ if (!existsSync5(journalPath) && (command === "list" || command === "show" || command === "gates")) {
4391
4109
  if (asJson) {
4392
4110
  out(JSON.stringify(command === "show" ? { run: null, actions: [] } : []));
4393
4111
  return 0;
@@ -4401,6 +4119,12 @@ ${COMMANDS}`);
4401
4119
  out("");
4402
4120
  return 0;
4403
4121
  }
4122
+ if (command === "notify") {
4123
+ return runNotifyTest(argv);
4124
+ }
4125
+ if (command === "allow") {
4126
+ return await runAllow(argv, journalPath);
4127
+ }
4404
4128
  if (command === "desktop") {
4405
4129
  return runDesktop();
4406
4130
  }
@@ -4432,6 +4156,32 @@ ${COMMANDS}`);
4432
4156
  journal.close();
4433
4157
  }
4434
4158
  }
4159
+ function runNotifyTest(argv) {
4160
+ if (!argv.includes("--test")) {
4161
+ throw new UsageError("notify takes --test, which sends one to see whether they reach you");
4162
+ }
4163
+ const why = canNotify();
4164
+ if (why !== void 0) {
4165
+ out(` ${style.quiet(`No notification sent: ${why}.`)}`);
4166
+ return 1;
4167
+ }
4168
+ desktopNotifier()({
4169
+ server: "synartesis",
4170
+ tool: "test",
4171
+ actionId: "00000000",
4172
+ approve: "this is only a test"
4173
+ });
4174
+ out("");
4175
+ out(` ${style.quiet("Sent one. If nothing appeared, notifications for it are switched off:")}`);
4176
+ out(
4177
+ ` ${style.quiet(
4178
+ platform() === "darwin" ? "macOS lists the ones osascript sends under Script Editor, in System Settings > Notifications." : "check that a notification daemon is running and notify-send is installed."
4179
+ )}`
4180
+ );
4181
+ out(` ${style.quiet("Either way, synartesis watch shows every held call as it happens.")}`);
4182
+ out("");
4183
+ return 0;
4184
+ }
4435
4185
  function runDesktop() {
4436
4186
  const found = findDesktop();
4437
4187
  if (found === void 0) {