synartesis 0.8.7 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,368 +1,235 @@
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,
20
36
  loadManifest,
21
37
  observeState,
22
38
  openJournal,
23
39
  parseManifest,
24
- pathBinaryMatches,
25
40
  pinBlock,
41
+ planInstall,
26
42
  planInverse,
27
43
  planRead,
28
44
  proxyCommand,
29
45
  qualify,
30
46
  resolvedRead,
31
47
  rule,
48
+ serversAt,
49
+ splitQualified,
32
50
  standing,
33
51
  style,
34
52
  toPayload,
35
53
  toResolvedRead,
36
54
  toolShapes,
55
+ trustsMarks,
37
56
  ungoverned,
38
57
  untested,
58
+ upstreamEnv,
39
59
  verifyAgainstServers,
40
60
  warnUntested,
41
61
  wasRefused
42
- } from "./chunk-JE7MOCZO.js";
62
+ } from "./chunk-AEEKBR5D.js";
43
63
  import {
44
64
  DriftConflict,
45
65
  ManifestError,
46
66
  RollbackHalted,
47
67
  SynartesisError,
48
- UpstreamError,
49
68
  changedLines,
50
69
  describe
51
70
  } from "./chunk-YVOO3PTV.js";
52
71
 
53
72
  // src/cli.ts
73
+ import { platform } from "os";
54
74
  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";
75
+ import { existsSync as existsSync5, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "fs";
76
+ import { createInterface } from "readline/promises";
77
+ import { dirname, join as join2, resolve } from "path";
64
78
  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;
79
+
80
+ // src/manifest/edit.ts
81
+ import { isMap, isNode, isScalar, isSeq, parseDocument, stringify } from "yaml";
82
+ var PolicyEditError = class extends Error {
83
+ name = "PolicyEditError";
84
+ };
85
+ function allowAlways(input) {
86
+ const { text, file, server, tool } = input;
87
+ const qualified = `${server}.${tool}`;
88
+ const before = parseManifest(text, file);
89
+ if (before.servers[server] === void 0) {
90
+ throw new PolicyEditError(`${file} has no server called ${server}`);
91
+ }
92
+ const current = createPolicyResolver(before).resolve(qualified);
93
+ const doc = parseDocument(text, { keepSourceTokens: true });
94
+ const tools = doc.get("tools", true);
95
+ const exact = isSeq(tools) ? tools.items.find((item) => isMap(item) && item.get("match") === qualified) : void 0;
96
+ let edited;
97
+ let how;
98
+ if (exact !== void 0) {
99
+ if (!isMap(exact) || exact.flow === true) {
100
+ throw new PolicyEditError(`the rule for ${qualified} is written on one line; change its gate by hand`);
101
+ }
102
+ const gate = exact.items.find((pair) => isScalar(pair.key) && pair.key.value === "gate");
103
+ if (gate !== void 0 && isScalar(gate.value) && gate.value.value === "never") {
104
+ how = "already";
105
+ edited = text;
106
+ } else if (gate !== void 0) {
107
+ const at = rangeOf(gate.value, qualified);
108
+ edited = text.slice(0, at[0]) + "never" + text.slice(at[1]);
109
+ how = "changed";
110
+ } else {
111
+ const cls = exact.items.find((pair) => isScalar(pair.key) && pair.key.value === "class");
112
+ const key = cls?.key;
113
+ if (cls === void 0 || !isScalar(key)) {
114
+ throw new PolicyEditError(`the rule for ${qualified} has no class; add one by hand first`);
115
+ }
116
+ const keyAt = rangeOf(key, qualified)[0];
117
+ const indent = " ".repeat(keyAt - lineStart(text, keyAt));
118
+ const lineEnd = endOfLine(text, rangeOf(cls.value, qualified)[1]);
119
+ edited = `${text.slice(0, lineEnd)}
120
+ ${indent}gate: never${text.slice(lineEnd)}`;
121
+ how = "changed";
99
122
  }
100
- const rules = parseManifest(
101
- `version: 1
102
- servers:
103
- ${key}:
104
- command: "true"
123
+ } else {
124
+ if (tools !== void 0 && (!isSeq(tools) || tools.flow === true || tools.items.length === 0)) {
125
+ throw new PolicyEditError(`the tools list in ${file} is not written one rule to a line; add the rule by hand`);
126
+ }
127
+ const base = current.matched ? { ...current.policy } : { class: "irreversible" };
128
+ delete base["match"];
129
+ delete base["refusal"];
130
+ const rule2 = { match: qualified, ...base, gate: "never" };
131
+ const said = [`# Let through without asking, by ${input.by} on ${input.date}.`];
132
+ if (base["class"] === "irreversible") {
133
+ said.push("# It still cannot be undone: it is recorded, and no longer held.");
134
+ }
135
+ const body = stringify([rule2], { lineWidth: 0 }).trimEnd().replace(/^- match: .*$/m, `- match: ${JSON.stringify(qualified)}`);
136
+ if (isSeq(tools)) {
137
+ const seqAt = rangeOf(tools, "tools")[0];
138
+ const indent = " ".repeat(seqAt - lineStart(text, seqAt));
139
+ const lines = [...said, ...body.split("\n")].map((line2) => indent + line2);
140
+ edited = insertAfter(text, rangeOf(tools, "tools")[2], `
141
+ ${lines.join("\n")}
142
+ `);
143
+ } else {
144
+ edited = `${text.replace(/\n*$/, "\n")}
105
145
  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;
146
+ ${[...said, ...body.split("\n")].map((line2) => ` ${line2}`).join("\n")}
147
+ `;
148
+ }
149
+ how = "added";
126
150
  }
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;
151
+ const pins = before.pins?.[server];
152
+ if (how !== "already" && pins !== void 0 && pins[tool] === void 0) {
153
+ if (input.pin === void 0) {
154
+ throw new PolicyEditError(`${server} is pinned, and ${tool} has no pin yet`);
132
155
  }
133
- lines.push(line2);
156
+ edited = addPin(edited, server, tool, input.pin, qualified);
134
157
  }
135
- return lines.join("\n").replace(/\s+$/, "");
158
+ const after = parseManifest(edited, file);
159
+ const policy = createPolicyResolver(after).resolve(qualified).policy;
160
+ confirmOnly(before, after, { server, tool }, current.policy, policy, input.pin);
161
+ return { text: edited, how, policy };
136
162
  }
137
- function serverKey(text) {
138
- const at = text.search(/^servers:[ \t]*$/m);
139
- if (at === -1) {
140
- return void 0;
163
+ function rangeOf(node, what) {
164
+ const range = isNode(node) ? node.range : void 0;
165
+ if (range === void 0 || range === null) {
166
+ throw new PolicyEditError(`could not find where ${what} is written in the policy`);
141
167
  }
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
- }
151
- }
152
- return void 0;
168
+ return range;
153
169
  }
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
- );
170
+ function lineStart(text, at) {
171
+ return text.lastIndexOf("\n", at - 1) + 1;
159
172
  }
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);
173
+ function endOfLine(text, at) {
174
+ const next = text.indexOf("\n", Math.max(0, at - 1));
175
+ return next === -1 ? text.length : next;
177
176
  }
178
- function summarise(text) {
179
- if (text === void 0) {
180
- return "";
177
+ function insertAfter(text, at, block2) {
178
+ if (at > 0 && text[at - 1] === "\n") {
179
+ return text.slice(0, at) + block2.slice(1) + text.slice(at);
181
180
  }
182
- const single = text.replace(/\s+/g, " ").trim();
183
- return single.length > 96 ? `${single.slice(0, 93)}...` : single;
181
+ const end = endOfLine(text, at);
182
+ return text.slice(0, end) + block2.replace(/\n$/, "") + text.slice(end);
184
183
  }
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;
184
+ function addPin(text, server, tool, pin, qualified) {
185
+ const doc = parseDocument(text, { keepSourceTokens: true });
186
+ const map = doc.getIn(["pins", server], true);
187
+ if (!isMap(map) || map.flow === true || map.items.length === 0) {
188
+ throw new PolicyEditError(`the pins for ${server} are not written one to a line; pin ${qualified} by hand`);
229
189
  }
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();
190
+ const at = rangeOf(map, "pins")[0];
191
+ const indent = " ".repeat(at - lineStart(text, at));
192
+ return insertAfter(text, rangeOf(map, "pins")[2], `
193
+ ${indent}${tool}: ${JSON.stringify(pin)}
194
+ `);
195
+ }
196
+ function confirmOnly(before, after, { server, tool }, was, now, pin) {
197
+ const qualified = `${server}.${tool}`;
198
+ const refuse = (why) => {
199
+ throw new PolicyEditError(`the edit came out wrong (${why}), so nothing was written`);
200
+ };
201
+ if (now.match !== qualified || now.gate !== "never") {
202
+ refuse(`${qualified} is not let through`);
265
203
  }
266
- if (tools.length === 0) {
267
- throw new ManifestError(`${options.name} exposes no tools, so there is no policy to write`);
204
+ if (now.class !== was.class) {
205
+ refuse(`${qualified} would change from ${was.class} to ${now.class}`);
268
206
  }
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
- );
207
+ const others = (manifest) => manifest.tools.filter((rule2) => rule2.match !== qualified).map((rule2) => canonical(rule2));
208
+ if (canonical(others(before)) !== canonical(others(after))) {
209
+ refuse("another rule changed");
275
210
  }
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
- };
211
+ if (canonical(before.servers) !== canonical(after.servers)) {
212
+ refuse("a server changed");
322
213
  }
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
- );
214
+ const pinned = before.pins?.[server];
215
+ const expected = pin === void 0 || pinned === void 0 || pinned[tool] !== void 0 ? before.pins : { ...before.pins, [server]: { ...pinned, [tool]: pin } };
216
+ if (canonical(expected ?? null) !== canonical(after.pins ?? null)) {
217
+ refuse("the pins changed");
340
218
  }
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
219
  }
353
220
 
354
221
  // 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())
222
+ import { z } from "zod";
223
+ var inversePlan = z.object({
224
+ server: z.string(),
225
+ tool: z.string(),
226
+ args: z.record(z.string(), z.unknown())
360
227
  });
361
- var observation = z2.union([
362
- z2.object({ present: z2.literal(true), value: z2.unknown() }),
363
- z2.object({ present: z2.literal(false) })
228
+ var observation = z.union([
229
+ z.object({ present: z.literal(true), value: z.unknown() }),
230
+ z.object({ present: z.literal(false) })
364
231
  ]);
365
- var toolResult = z2.looseObject({ isError: z2.boolean().default(false) });
232
+ var toolResult = z.looseObject({ isError: z.boolean().default(false) });
366
233
  function sameState(a, b) {
367
234
  return canonical(a) === canonical(b);
368
235
  }
@@ -582,7 +449,7 @@ ${seen}` : seen;
582
449
  steps.push({
583
450
  ...describeStep(action),
584
451
  kind: "revert",
585
- reason: verified ? "state matches; applying inverse" : forcedOver ?? unverifiedBecause(action),
452
+ reason: verified ? "unchanged since, so safe to put back" : forcedOver ?? unverifiedBecause(action),
586
453
  verified,
587
454
  plan,
588
455
  ...rebuilt.inverse === void 0 ? {} : { replanned: true },
@@ -696,11 +563,11 @@ async function executeInverse(router, plan, idempotencyKey, signal) {
696
563
  _meta: { [IDEMPOTENCY_META_KEY]: idempotencyKey }
697
564
  }
698
565
  },
699
- z2.looseObject({}),
566
+ z.looseObject({}),
700
567
  { signal }
701
568
  );
702
569
  } catch (error) {
703
- return { ok: false, rejected: false, message: describe(error) };
570
+ return { ok: false, rejected: upstream.classify?.(error) !== void 0, message: describe(error) };
704
571
  }
705
572
  const parsed = toolResult.safeParse(raw);
706
573
  if (parsed.success && parsed.data.isError) {
@@ -714,10 +581,10 @@ async function executeInverse(router, plan, idempotencyKey, signal) {
714
581
  }
715
582
 
716
583
  // 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) })
584
+ import { z as z2 } from "zod";
585
+ var observation2 = z2.union([
586
+ z2.object({ present: z2.literal(true), value: z2.unknown() }),
587
+ z2.object({ present: z2.literal(false) })
721
588
  ]);
722
589
  function sameState2(a, b) {
723
590
  return canonical(a) === canonical(b);
@@ -1035,7 +902,7 @@ function shortList() {
1035
902
  }
1036
903
 
1037
904
  // src/watch.ts
1038
- import { existsSync as existsSync2 } from "fs";
905
+ import { existsSync } from "fs";
1039
906
 
1040
907
  // src/keys.ts
1041
908
  var SEQUENCE = /^\u001b(\[[0-9;?]*[ -\/]*[@-~]|O[@-~])/;
@@ -1157,6 +1024,7 @@ function summariseArgs(args, limit = 60) {
1157
1024
  }
1158
1025
 
1159
1026
  // src/watch.ts
1027
+ var HOUR_MS = 60 * 60 * 1e3;
1160
1028
  var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1161
1029
  var NOTICE_TICKS = 26;
1162
1030
  function line(action, now) {
@@ -1216,7 +1084,7 @@ function render(journal, options, tick, view) {
1216
1084
  });
1217
1085
  out2.push("");
1218
1086
  out2.push(
1219
- canDecide(options) ? ` ${keyHint("a", "approve")} ${keyHint("d", "deny")} ${keyHint("j/k", "move")} ${keyHint("q", "quit")}` : ` ${style.quiet(`${options.approveWith} approve --all`)}`
1087
+ canDecide(options) ? ` ${keyHint("a", "approve")} ${keyHint("A", "and for an hour")} ${keyHint("d", "deny")} ${keyHint("j/k", "move")} ${keyHint("q", "quit")}` : ` ${style.quiet(`${options.approveWith} approve --all`)}`
1220
1088
  );
1221
1089
  }
1222
1090
  if (view.notice !== "") {
@@ -1262,7 +1130,7 @@ async function* terminalKeys() {
1262
1130
  async function watch(options) {
1263
1131
  let journal;
1264
1132
  const open = () => {
1265
- if (journal === void 0 && existsSync2(options.journalPath)) {
1133
+ if (journal === void 0 && existsSync(options.journalPath)) {
1266
1134
  journal = openJournal(options.journalPath, { mustExist: true });
1267
1135
  }
1268
1136
  return journal;
@@ -1275,7 +1143,7 @@ async function watch(options) {
1275
1143
  const ready = open();
1276
1144
  return ready === void 0 ? waitingForJournal(options, tick2) : render(ready, options, tick2, view);
1277
1145
  };
1278
- const decide = (approve) => {
1146
+ const decide = (approve, forAnHour = false) => {
1279
1147
  const ready = open();
1280
1148
  if (ready === void 0 || options.decideAs === void 0) {
1281
1149
  return;
@@ -1285,8 +1153,11 @@ async function watch(options) {
1285
1153
  if (action === void 0) {
1286
1154
  return;
1287
1155
  }
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`;
1156
+ const changed = approve ? ready.approve(action.id, options.decideAs) : ready.denyByPerson(action.id, options.decideAs, "denied from the watch view");
1157
+ if (changed && approve && forAnHour) {
1158
+ ready.allow(action.server, action.tool, options.decideAs, new Date(Date.now() + HOUR_MS).toISOString());
1159
+ }
1160
+ 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
1161
  view.noticeUntil = tick + NOTICE_TICKS;
1291
1162
  view.cursor = 0;
1292
1163
  };
@@ -1299,6 +1170,9 @@ async function watch(options) {
1299
1170
  case "a":
1300
1171
  decide(true);
1301
1172
  return;
1173
+ case "A":
1174
+ decide(true, true);
1175
+ return;
1302
1176
  case "d":
1303
1177
  decide(false);
1304
1178
  return;
@@ -1349,7 +1223,7 @@ async function watch(options) {
1349
1223
  if (options.maxTicks !== void 0 && tick + 1 >= options.maxTicks) {
1350
1224
  break;
1351
1225
  }
1352
- await new Promise((resolve4) => setTimeout(resolve4, interval));
1226
+ await new Promise((resolve2) => setTimeout(resolve2, interval));
1353
1227
  }
1354
1228
  return 0;
1355
1229
  } finally {
@@ -1364,613 +1238,17 @@ async function watch(options) {
1364
1238
  await reader?.return?.(void 0);
1365
1239
  await reading;
1366
1240
  })(),
1367
- new Promise((resolve4) => setTimeout(resolve4, 50).unref())
1241
+ new Promise((resolve2) => setTimeout(resolve2, 50).unref())
1368
1242
  ]);
1369
1243
  journal?.close();
1370
1244
  }
1371
1245
  }
1372
1246
 
1373
1247
  // 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
- }
1248
+ import { existsSync as existsSync3 } from "fs";
1972
1249
 
1973
1250
  // src/install/connections.ts
1251
+ import { existsSync as existsSync2 } from "fs";
1974
1252
  var ACTIVE_WITHIN_MS = 2 * 60 * 1e3;
1975
1253
  function lastSeenByServer(journal) {
1976
1254
  return journal.lastSeenPerServer();
@@ -1979,7 +1257,7 @@ function commandMissing(command) {
1979
1257
  if (command === void 0) {
1980
1258
  return false;
1981
1259
  }
1982
- return (command.includes("/") || command.includes("\\")) && !existsSync5(command);
1260
+ return (command.includes("/") || command.includes("\\")) && !existsSync2(command);
1983
1261
  }
1984
1262
  function scan(journal, cwd) {
1985
1263
  const seen = journal === void 0 ? /* @__PURE__ */ new Map() : lastSeenByServer(journal);
@@ -2036,6 +1314,7 @@ function needsConnecting(groups) {
2036
1314
  }
2037
1315
 
2038
1316
  // src/console.ts
1317
+ var HOUR_MS2 = 60 * 60 * 1e3;
2039
1318
  var FRAMES2 = [
2040
1319
  "\u280B",
2041
1320
  "\u2819",
@@ -2208,7 +1487,7 @@ function connectionsView(screen, options) {
2208
1487
  return [
2209
1488
  ` ${style.quiet("No MCP client config was found on this machine.")}`,
2210
1489
  "",
2211
- ` ${style.quiet("Looked for Claude Code, Claude Desktop, Cursor and Codex.")}`
1490
+ ` ${style.quiet(LOOKED_FOR)}`
2212
1491
  ];
2213
1492
  }
2214
1493
  const rows = connectionRows(screen);
@@ -2263,7 +1542,7 @@ function footer(screen, options) {
2263
1542
  const what = screen.confirmingForce ? `undo ${screen.confirmingLabel ?? "this session"} anyway, losing that change?` : `undo ${screen.confirmingLabel ?? "this session"}?`;
2264
1543
  return ["", ` ${style.accent(what)} ${keyHint2("y", "yes")} ${keyHint2("n", "no")}`];
2265
1544
  }
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" ? [
1545
+ const keys = screen.mode === "connections" ? [keyHint2("enter", "connect"), keyHint2("a", "connect all"), keyHint2("r", "rescan"), keyHint2("j/k", "move"), keyHint2("h", "back")] : screen.mode === "gates" ? [keyHint2("a", "approve"), keyHint2("A", "and for an hour"), keyHint2("d", "deny"), keyHint2("j/k", "move"), keyHint2("r", "runs")] : screen.mode === "run" ? [
2267
1546
  keyHint2("l", "check now"),
2268
1547
  keyHint2("p", "preview undo"),
2269
1548
  keyHint2("u", "undo"),
@@ -2314,7 +1593,7 @@ async function* terminalKeys2() {
2314
1593
  async function openConsole(options) {
2315
1594
  let journal;
2316
1595
  const open = () => {
2317
- if (journal === void 0 && existsSync6(options.journalPath)) {
1596
+ if (journal === void 0 && existsSync3(options.journalPath)) {
2318
1597
  journal = openJournal(options.journalPath, { mustExist: true });
2319
1598
  }
2320
1599
  return journal;
@@ -2401,7 +1680,7 @@ async function openConsole(options) {
2401
1680
  }
2402
1681
  return ["", ` ${style.quiet("nothing to undo in this session")}`];
2403
1682
  };
2404
- const decide = (approve) => {
1683
+ const decide = (approve, forAnHour = false) => {
2405
1684
  const ready = open();
2406
1685
  if (ready === void 0) {
2407
1686
  return;
@@ -2411,9 +1690,12 @@ async function openConsole(options) {
2411
1690
  if (action === void 0) {
2412
1691
  return;
2413
1692
  }
2414
- const changed = approve ? ready.approve(action.id, options.decideAs) : ready.deny(action.id, options.decideAs, "denied from the console");
1693
+ const changed = approve ? ready.approve(action.id, options.decideAs) : ready.denyByPerson(action.id, options.decideAs, "denied from the console");
1694
+ if (changed && approve && forAnHour) {
1695
+ ready.allow(action.server, action.tool, options.decideAs, new Date(Date.now() + HOUR_MS2).toISOString());
1696
+ }
2415
1697
  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`
1698
+ !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
1699
  );
2418
1700
  screen.cursor = 0;
2419
1701
  };
@@ -2566,6 +1848,11 @@ async function openConsole(options) {
2566
1848
  }
2567
1849
  return;
2568
1850
  }
1851
+ case "A":
1852
+ if (screen.mode === "gates") {
1853
+ decide(true, true);
1854
+ }
1855
+ return;
2569
1856
  case "a":
2570
1857
  if (screen.mode === "gates") {
2571
1858
  decide(true);
@@ -2690,7 +1977,7 @@ async function openConsole(options) {
2690
1977
  if (options.maxTicks !== void 0 && tick + 1 >= options.maxTicks) {
2691
1978
  break;
2692
1979
  }
2693
- await new Promise((resolve4) => setTimeout(resolve4, interval));
1980
+ await new Promise((resolve2) => setTimeout(resolve2, interval));
2694
1981
  }
2695
1982
  return 0;
2696
1983
  } finally {
@@ -2706,40 +1993,40 @@ async function openConsole(options) {
2706
1993
  await reader?.return?.(void 0);
2707
1994
  await reading;
2708
1995
  })(),
2709
- new Promise((resolve4) => setTimeout(resolve4, 50).unref())
1996
+ new Promise((resolve2) => setTimeout(resolve2, 50).unref())
2710
1997
  ]);
2711
1998
  journal?.close();
2712
1999
  }
2713
2000
  }
2714
2001
 
2715
2002
  // src/desktop.ts
2716
- import { existsSync as existsSync7 } from "fs";
2717
- import { homedir as homedir2 } from "os";
2718
- import { join as join2 } from "path";
2003
+ import { existsSync as existsSync4 } from "fs";
2004
+ import { homedir } from "os";
2005
+ import { join } from "path";
2719
2006
  var RELEASES = "https://github.com/ArhaanDev24/Synartesis/releases";
2720
2007
  function candidates(platform2 = process.platform) {
2721
- const home = homedir2();
2008
+ const home = homedir();
2722
2009
  if (platform2 === "darwin") {
2723
2010
  return [
2724
2011
  "/Applications/Synartesis.app",
2725
- join2(home, "Applications/Synartesis.app")
2012
+ join(home, "Applications/Synartesis.app")
2726
2013
  ];
2727
2014
  }
2728
2015
  if (platform2 === "win32") {
2729
- const local = process.env["LOCALAPPDATA"] ?? join2(home, "AppData/Local");
2016
+ const local = process.env["LOCALAPPDATA"] ?? join(home, "AppData/Local");
2730
2017
  return [
2731
- join2(local, "Programs/Synartesis/Synartesis.exe"),
2732
- join2(process.env["PROGRAMFILES"] ?? "C:/Program Files", "Synartesis/Synartesis.exe")
2018
+ join(local, "Programs/Synartesis/Synartesis.exe"),
2019
+ join(process.env["PROGRAMFILES"] ?? "C:/Program Files", "Synartesis/Synartesis.exe")
2733
2020
  ];
2734
2021
  }
2735
2022
  return [
2736
2023
  "/opt/Synartesis/synartesis-desktop",
2737
2024
  "/usr/bin/synartesis-desktop",
2738
- join2(home, ".local/bin/synartesis-desktop"),
2739
- join2(home, "Applications/Synartesis.AppImage")
2025
+ join(home, ".local/bin/synartesis-desktop"),
2026
+ join(home, "Applications/Synartesis.AppImage")
2740
2027
  ];
2741
2028
  }
2742
- function findDesktop(platform2 = process.platform, here = existsSync7) {
2029
+ function findDesktop(platform2 = process.platform, here = existsSync4) {
2743
2030
  for (const path of candidates(platform2)) {
2744
2031
  if (!here(path)) {
2745
2032
  continue;
@@ -2782,7 +2069,7 @@ var COMMANDS = `
2782
2069
  undo -- all in one place, with
2783
2070
  the arrow keys. Everything
2784
2071
  below can be done from it.
2785
- synartesis install [--client <name>] [--dry-run] [--print]
2072
+ synartesis install [--client <name>] [--remote] [--dry-run] [--print]
2786
2073
  synartesis uninstall [--client <name>]
2787
2074
  synartesis status
2788
2075
  synartesis init <server> -- <command> [args...] [--manifest <path>]
@@ -2801,14 +2088,19 @@ var COMMANDS = `
2801
2088
  synartesis watch [--by <name>] [--journal <path>]
2802
2089
  synartesis approve [actionId|--all] [--by <name>] [--journal <path>]
2803
2090
  synartesis deny [actionId|--all] [--by <name>] [--reason <text>] [--journal <path>]
2091
+ synartesis allow [<server.tool> --for <30m|2h> | --always | --stop]
2804
2092
  synartesis resolve [actionId] --applied|--failed [--by <name>] [--reason <text>]
2093
+ synartesis notify --test
2805
2094
  synartesis undo [runId] [--to <seq>] [--dry-run] [--replan] [--force [--yes]]
2806
2095
  [--manifest <path>] [--journal <path>]
2807
2096
 
2808
2097
  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
2098
+ Cursor, Codex, Gemini CLI, Copilot CLI, Antigravity and Devin Desktop (or
2099
+ Windsurf) already list, writes a policy covering all of it -- using the ones
2100
+ that ship where they fit -- and points each entry at the proxy. The original config
2811
2101
  is copied aside first, and uninstall puts it back. status says what is covered.
2102
+ A hosted server (a url rather than a command) is covered only with --remote,
2103
+ through mcp-remote, which opens your browser to sign in.
2812
2104
 
2813
2105
  desktop opens the window, if it is installed. It is a separate download --
2814
2106
  shipping it through npm would put a browser engine inside every install of
@@ -2840,7 +2132,8 @@ undo stops when somebody has changed the resource since, rather than writing
2840
2132
  over them. Three ways past that, and it prints all three: leave it, put the
2841
2133
  resource back as the run left it and --replan, or --force to overwrite.
2842
2134
 
2843
- --client claude-code, claude-desktop, cursor or codex; all by default
2135
+ --client claude-code, claude-desktop, cursor, codex, gemini-cli,
2136
+ copilot-cli, antigravity, devin or windsurf; all by default
2844
2137
  --print show the entries install would write, and write nothing
2845
2138
  --full show every argument, snapshot and inverse in full, nothing elided
2846
2139
  --live read each resource as it is now and say what has changed since
@@ -2853,6 +2146,8 @@ resource back as the run left it and --replan, or --force to overwrite.
2853
2146
  --once watch prints the current state and exits
2854
2147
  --json machine-readable output for list, show and gates
2855
2148
  --dry-run read current state and print the plan without changing anything
2149
+ --unattended approve with no terminal, from a script. Recorded as unattended,
2150
+ so it can be told apart from a yes a person typed
2856
2151
  --applied resolve: the call did land, though nothing recorded it
2857
2152
  --failed resolve: the call never landed
2858
2153
  --replan rebuild each undo from the current manifest, for a run recorded
@@ -2912,7 +2207,9 @@ var TAKES_VALUE = /* @__PURE__ */ new Set([
2912
2207
  "--by",
2913
2208
  "--reason",
2914
2209
  "--older-than",
2915
- "--client"
2210
+ "--client",
2211
+ "--for",
2212
+ "--confirm"
2916
2213
  ]);
2917
2214
  function positional(argv) {
2918
2215
  const values = [];
@@ -2929,6 +2226,31 @@ function positional(argv) {
2929
2226
  }
2930
2227
  return values;
2931
2228
  }
2229
+ async function startAsTheClientWould(manifestPath, name, spec, session) {
2230
+ const client = clientEnvFor(manifestPath, name);
2231
+ const source = client === void 0 ? { kind: "manifest" } : { kind: "client", env: client.env };
2232
+ const recorded = session?.journal.runServer(session.runId, name);
2233
+ if (session !== void 0 && recorded !== void 0) {
2234
+ const now = fingerprint2(
2235
+ session.journal.fingerprintKey(),
2236
+ upstreamEnv(name, spec, source),
2237
+ Object.keys(recorded.fingerprints)
2238
+ );
2239
+ const changed = differing(recorded.fingerprints, now);
2240
+ if (changed.length > 0) {
2241
+ const one = changed.length === 1;
2242
+ throw new ConfigError(
2243
+ `${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.`
2244
+ );
2245
+ }
2246
+ }
2247
+ const cwd = recorded?.cwd ?? client?.cwd;
2248
+ return await connectUpstream(name, spec, {
2249
+ env: source,
2250
+ stderr: "capture",
2251
+ ...cwd === void 0 ? {} : { cwd }
2252
+ });
2253
+ }
2932
2254
  async function runPin(argv) {
2933
2255
  const path = findManifest(flag(argv, "--manifest"));
2934
2256
  const manifest = loadManifest(path);
@@ -2936,13 +2258,7 @@ async function runPin(argv) {
2936
2258
  const upstreams = [];
2937
2259
  try {
2938
2260
  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
- });
2261
+ const upstream = await startAsTheClientWould(path, name, spec);
2946
2262
  upstreams.push(upstream);
2947
2263
  shapes.set(name, await toolShapes(upstream));
2948
2264
  }
@@ -2988,23 +2304,40 @@ async function runCheck(argv) {
2988
2304
  out(` ${style.accent(line2)}`);
2989
2305
  }
2990
2306
  }
2307
+ for (const [name, spec] of Object.entries(manifest.servers)) {
2308
+ for (const [header2, value] of Object.entries(spec.url === void 0 ? {} : spec.headers ?? {})) {
2309
+ if (!value.includes("${")) {
2310
+ out("");
2311
+ for (const line2 of wrapped(
2312
+ `${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.`,
2313
+ 74
2314
+ )) {
2315
+ out(` ${style.accent(line2)}`);
2316
+ }
2317
+ }
2318
+ }
2319
+ }
2991
2320
  const upstreams = [];
2992
2321
  const offered = /* @__PURE__ */ new Map();
2322
+ const trustedReads = /* @__PURE__ */ new Map();
2993
2323
  try {
2994
2324
  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
- );
2325
+ upstreams.push(await startAsTheClientWould(path, name, spec));
3004
2326
  }
3005
2327
  await verifyAgainstServers(upstreams, manifest);
2328
+ const resolver = createPolicyResolver(manifest);
3006
2329
  for (const upstream of upstreams) {
3007
- offered.set(upstream.name, (await toolShapes(upstream)).map((tool) => tool.name));
2330
+ const shapes = await toolShapes(upstream);
2331
+ const trusted = shapes.filter(
2332
+ (tool) => tool.readOnly === true && trustsMarks(manifest, upstream.name) && !resolver.resolve(`${upstream.name}.${tool.name}`).matched
2333
+ ).map((tool) => tool.name);
2334
+ offered.set(
2335
+ upstream.name,
2336
+ shapes.map((tool) => tool.name).filter((tool) => !trusted.includes(tool))
2337
+ );
2338
+ if (trusted.length > 0) {
2339
+ trustedReads.set(upstream.name, trusted);
2340
+ }
3008
2341
  }
3009
2342
  } finally {
3010
2343
  for (const upstream of upstreams) {
@@ -3051,8 +2384,8 @@ async function runCheck(argv) {
3051
2384
  `${String(total)} tool${total === 1 ? "" : "s"} here ${total === 1 ? "has" : "have"} no policy, so ${total === 1 ? "it is" : "they are"} treated as`
3052
2385
  )}`
3053
2386
  );
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.`)}`);
2387
+ out(` ${style.quiet("irreversible and held for a person every time an agent calls")}`);
2388
+ out(` ${style.quiet(`${total === 1 ? "it" : "one"}. Write a policy, or allow it, for any you would rather it got on with.`)}`);
3056
2389
  out("");
3057
2390
  for (const entry of uncovered) {
3058
2391
  for (const line2 of wrapped(entry.tools.join(", "), 60)) {
@@ -3060,10 +2393,23 @@ async function runCheck(argv) {
3060
2393
  }
3061
2394
  }
3062
2395
  }
2396
+ if (trustedReads.size > 0) {
2397
+ out("");
2398
+ out(
2399
+ ` ${style.label("read as reads")} ${style.quiet("no rule, but the server marks these read-only, so they are not held:")}`
2400
+ );
2401
+ for (const [server, tools] of trustedReads) {
2402
+ for (const line2 of wrapped(tools.join(", "), 60)) {
2403
+ out(` ${style.quiet(server.padEnd(8))} ${line2}`);
2404
+ }
2405
+ }
2406
+ out(` ${style.quiet("trust_annotations: false on a server turns this off for it.")}`);
2407
+ }
3063
2408
  out("");
2409
+ const used = existsSync5(findJournal(flag(argv, "--journal"), path));
3064
2410
  hint(
3065
2411
  firstOf(
3066
- () => notPinned(manifest),
2412
+ () => used ? notPinned(manifest) : void 0,
3067
2413
  () => ({
3068
2414
  why: "this is sound; to see which clients it covers",
3069
2415
  run: "status",
@@ -3084,12 +2430,19 @@ async function runInstall(argv) {
3084
2430
  if (sites.length === 0) {
3085
2431
  out("");
3086
2432
  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.")}`);
2433
+ out(` ${style.quiet(LOOKED_FOR)}`);
3088
2434
  out("");
3089
2435
  return 0;
3090
2436
  }
3091
- const invoker = invokerFor(version(), fileURLToPath2(import.meta.url));
3092
- const { plans, yaml } = await planInstall(sites, manifestPath, invoker);
2437
+ const invoker = invokerFor(version(), fileURLToPath(import.meta.url));
2438
+ const { plans, yaml } = await planInstall(sites, manifestPath, invoker, void 0, {
2439
+ remote: argv.includes("--remote"),
2440
+ start: !printOnly,
2441
+ starting: (name) => {
2442
+ process.stderr.write(` ${style.quiet(`starting ${name} to see what it offers...`)}
2443
+ `);
2444
+ }
2445
+ });
3093
2446
  const total = plans.reduce((sum, plan) => sum + plan.servers.length, 0);
3094
2447
  out("");
3095
2448
  if (invoker.note !== void 0 && total > 0) {
@@ -3102,16 +2455,38 @@ async function runInstall(argv) {
3102
2455
  out(` ${style.strong(plan.site.label)} ${style.quiet(plan.site.scope)}`);
3103
2456
  out(` ${style.quiet(plan.site.path)}`);
3104
2457
  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)`);
2458
+ const note = server.again === true ? style.quiet("already in your policy from an earlier install; covered again") : server.unstarted === true ? style.quiet(
2459
+ 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`
2460
+ ) : 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
2461
  out(` ${style.strong(server.name.padEnd(18))} ${note}`);
3107
2462
  if (server.provenance === "documented") {
3108
2463
  out(
3109
2464
  ` ${" ".repeat(18)} ${style.accent("never run against the real server -- check it before trusting undo")}`
3110
2465
  );
3111
2466
  }
2467
+ if (server.direct !== void 0) {
2468
+ for (const line2 of [
2469
+ `hosted at ${server.direct}, reached directly with the headers your`,
2470
+ "client gave it. They now sit in this entry's env; the policy names them."
2471
+ ]) {
2472
+ out(` ${" ".repeat(18)} ${style.quiet(line2)}`);
2473
+ }
2474
+ }
2475
+ if (server.bridged !== void 0) {
2476
+ for (const line2 of [
2477
+ `hosted at ${server.bridged}, reached through mcp-remote,`,
2478
+ "which signs you in and keeps that sign-in itself; synartesis never sees it.",
2479
+ "Hosted tools are named differently from local packages, so no shipped",
2480
+ "policy applies: its writes are held until you write rules for them."
2481
+ ]) {
2482
+ out(` ${" ".repeat(18)} ${style.quiet(line2)}`);
2483
+ }
2484
+ }
3112
2485
  }
3113
2486
  for (const skip of plan.skipped) {
3114
- out(` ${style.quiet(skip.name.padEnd(18))} ${style.quiet(skip.why)}`);
2487
+ for (const [at, line2] of wrapped(skip.why, 58).entries()) {
2488
+ out(` ${style.quiet((at === 0 ? skip.name : "").padEnd(18))} ${style.quiet(line2)}`);
2489
+ }
3115
2490
  }
3116
2491
  if (plan.servers.length === 0 && plan.skipped.length === 0) {
3117
2492
  out(` ${style.quiet("no servers listed")}`);
@@ -3195,13 +2570,13 @@ async function runUninstall(argv) {
3195
2570
  }
3196
2571
  function openIfPresent(journalPath) {
3197
2572
  try {
3198
- return existsSync8(journalPath) ? openJournal(journalPath, { mustExist: true }) : void 0;
2573
+ return existsSync5(journalPath) ? openJournal(journalPath, { mustExist: true }) : void 0;
3199
2574
  } catch {
3200
2575
  return void 0;
3201
2576
  }
3202
2577
  }
3203
2578
  async function connectThese(targets, manifestPath) {
3204
- const invoker = invokerFor(version(), fileURLToPath2(import.meta.url));
2579
+ const invoker = invokerFor(version(), fileURLToPath(import.meta.url));
3205
2580
  const wanted = /* @__PURE__ */ new Map();
3206
2581
  const sites = /* @__PURE__ */ new Map();
3207
2582
  for (const target of targets) {
@@ -3209,14 +2584,15 @@ async function connectThese(targets, manifestPath) {
3209
2584
  sites.set(key, target.site);
3210
2585
  (wanted.get(key) ?? wanted.set(key, /* @__PURE__ */ new Set()).get(key))?.add(target.server);
3211
2586
  }
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);
2587
+ const { plans, yaml } = await planInstall(
2588
+ [...sites.values()],
2589
+ manifestPath,
2590
+ invoker,
2591
+ (site, name) => wanted.get(`${site.path}${site.scope}`)?.has(name) === true,
2592
+ // Picked by name from the list, which is the asking.
2593
+ { remote: true }
2594
+ );
2595
+ const applied = applyInstall(plans, manifestPath, yaml);
3220
2596
  const count = applied.reduce((sum, entry) => sum + entry.servers.length, 0);
3221
2597
  if (count === 0) {
3222
2598
  return "nothing was connected; see the reasons above";
@@ -3228,7 +2604,7 @@ function runStatus(argv) {
3228
2604
  const journalPath = findJournal(flag(argv, "--journal"), manifestPath);
3229
2605
  out("");
3230
2606
  out(
3231
- ` ${style.label("policy")} ${existsSync8(manifestPath) ? style.strong(manifestPath) : style.quiet(`${manifestPath} (none yet)`)}`
2607
+ ` ${style.label("policy")} ${existsSync5(manifestPath) ? style.strong(manifestPath) : style.quiet(`${manifestPath} (none yet)`)}`
3232
2608
  );
3233
2609
  out(
3234
2610
  ` ${style.label("journal")} ${bytesOf(journalPath) === void 0 ? style.quiet(`${journalPath} (none yet)`) : `${style.strong(journalPath)} ${style.quiet(sizeOf(journalPath))}`}`
@@ -3239,7 +2615,7 @@ function runStatus(argv) {
3239
2615
  const groups = scan(journal, process.cwd());
3240
2616
  if (groups.length === 0) {
3241
2617
  out(` ${style.quiet("No MCP client config found.")}`);
3242
- out(` ${style.quiet("Looked for Claude Code, Claude Desktop, Cursor and Codex.")}`);
2618
+ out(` ${style.quiet(LOOKED_FOR)}`);
3243
2619
  out("");
3244
2620
  return 0;
3245
2621
  }
@@ -3284,7 +2660,7 @@ async function runInit(argv) {
3284
2660
  }
3285
2661
  const path = findManifest(flag(argv, "--manifest"));
3286
2662
  const force = argv.includes("--force");
3287
- const present = existsSync8(path);
2663
+ const present = existsSync5(path);
3288
2664
  if (present && force) {
3289
2665
  throw new UsageError(
3290
2666
  `--force would discard ${path}. Delete it yourself if that is what you want; init will otherwise add to it.`
@@ -3294,11 +2670,11 @@ async function runInit(argv) {
3294
2670
  name,
3295
2671
  command,
3296
2672
  args: argv.slice(separator + 2),
3297
- ...present ? { existing: readFileSync4(path, "utf8") } : {}
2673
+ ...present ? { existing: readFileSync(path, "utf8") } : {}
3298
2674
  });
3299
2675
  parseManifest(draft.yaml, path);
3300
- mkdirSync2(dirname3(resolve3(path)), { recursive: true, mode: 448 });
3301
- writeFileSync3(path, draft.yaml);
2676
+ mkdirSync(dirname(resolve(path)), { recursive: true, mode: 448 });
2677
+ writeFileSync(path, draft.yaml);
3302
2678
  out("");
3303
2679
  out(` ${style.label(present ? "extended" : "wrote")} ${style.strong(path)}`);
3304
2680
  out(` ${rule(54)}`);
@@ -3313,7 +2689,7 @@ async function runInit(argv) {
3313
2689
  out(` ${style.quiet("Read it before you trust it, then point your MCP client at:")}`);
3314
2690
  }
3315
2691
  out("");
3316
- out(` ${style.accent(`${proxyCommand()} --manifest ${resolve3(path)}`)}`);
2692
+ out(` ${style.accent(`${proxyCommand()} --manifest ${resolve(path)}`)}`);
3317
2693
  out("");
3318
2694
  return 0;
3319
2695
  }
@@ -3459,8 +2835,36 @@ function runList(journal, asJson, journalPath) {
3459
2835
  ` ${"session".padEnd(width)} ${"started".padEnd(13)} ${"did".padEnd(26)} ${"state".padEnd(26)} agent`
3460
2836
  )
3461
2837
  );
2838
+ let quiet = [];
2839
+ const flushQuiet = () => {
2840
+ if (quiet.length === 1) {
2841
+ const [only] = quiet;
2842
+ if (only !== void 0) {
2843
+ const did = didWhat(journal, only, 0);
2844
+ out(
2845
+ ` ${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 ?? "-")}`
2846
+ );
2847
+ }
2848
+ } else if (quiet.length > 1) {
2849
+ const newest = quiet[0];
2850
+ const oldest = quiet[quiet.length - 1];
2851
+ if (newest !== void 0 && oldest !== void 0) {
2852
+ out(
2853
+ ` ${style.quiet(
2854
+ `${"".padEnd(width)} ${String(quiet.length)} sessions with nothing in them, ${shortTime(oldest.startedAt).trim()} to ${shortTime(newest.startedAt).trim()}`
2855
+ )}`
2856
+ );
2857
+ }
2858
+ }
2859
+ quiet = [];
2860
+ };
3462
2861
  for (const run of runs) {
3463
2862
  const actions = counted2(run.id);
2863
+ if (actions.actions === 0) {
2864
+ quiet.push(run);
2865
+ continue;
2866
+ }
2867
+ flushQuiet();
3464
2868
  const { unknown, waiting } = actions;
3465
2869
  const notes = [
3466
2870
  unknown === 0 ? "" : `${String(unknown)} of unknown outcome`,
@@ -3472,6 +2876,7 @@ function runList(journal, asJson, journalPath) {
3472
2876
  ` ${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
2877
  );
3474
2878
  }
2879
+ flushQuiet();
3475
2880
  out("");
3476
2881
  if ((bytesOf(journalPath) ?? 0) > PRUNE_NAG_BYTES) {
3477
2882
  out(` ${style.quiet(`This journal is ${sizeOf(journalPath)}; synartesis prune reclaims what is old enough to lose.`)}`);
@@ -3486,12 +2891,15 @@ function runList(journal, asJson, journalPath) {
3486
2891
  async function runShow(argv, journal, asJson) {
3487
2892
  const full = argv.includes("--full");
3488
2893
  const runs = [...journal.listRuns()].reverse();
3489
- const run = pick(runs, positional(argv)[1], RUN, true);
2894
+ const given = positional(argv)[1];
2895
+ const tally2 = journal.tallyRuns();
2896
+ const run = (given === void 0 ? runs.find((one) => (tally2.get(one.id)?.actions ?? 0) > 0) : void 0) ?? pick(runs, given, RUN, true);
3490
2897
  const runId = run.id;
3491
2898
  const inspection = argv.includes("--live") && journal.getActions(runId).length > 0 ? await withUpstreams(
3492
2899
  findManifest(flag(argv, "--manifest")),
3493
2900
  async (router) => await inspect({ journal, router, runId }),
3494
- serversUsedBy(journal, runId)
2901
+ serversUsedBy(journal, runId),
2902
+ { journal, runId }
3495
2903
  ) : void 0;
3496
2904
  if (asJson) {
3497
2905
  out(
@@ -3514,7 +2922,7 @@ async function runShow(argv, journal, asJson) {
3514
2922
  out(` ${style.quiet("agent ")} ${run.label ?? "-"}`);
3515
2923
  out(` ${style.quiet("started")} ${fullTime(run.startedAt)} ${style.quiet(ago(run.startedAt))}`);
3516
2924
  out(
3517
- ` ${style.quiet("status ")} ${run.status}` + (run.endedAt === void 0 ? "" : style.quiet(` ended ${fullTime(run.endedAt)}`))
2925
+ ` ${style.quiet("status ")} ${RUN_STATUS[run.status]}` + (run.endedAt === void 0 ? "" : style.quiet(` ended ${fullTime(run.endedAt)}`))
3518
2926
  );
3519
2927
  const actions = journal.getActions(runId);
3520
2928
  if (actions.length === 0) {
@@ -3576,7 +2984,7 @@ async function runShow(argv, journal, asJson) {
3576
2984
  }
3577
2985
  out("");
3578
2986
  }
3579
- out(` ${summarise2(actions)}`);
2987
+ out(` ${summarise(actions)}`);
3580
2988
  if (inspection !== void 0) {
3581
2989
  out("");
3582
2990
  const spoiled = inspection.resources.some((found) => found.condition === "changed");
@@ -3629,14 +3037,20 @@ var CLASS_MARK = {
3629
3037
  unclassified: "?"
3630
3038
  };
3631
3039
  var BADGE_WIDTH = "irreversible".length + 2;
3040
+ var RUN_STATUS = {
3041
+ active: "still running",
3042
+ complete: "finished",
3043
+ rolled_back: "undone",
3044
+ partial: "partly undone"
3045
+ };
3632
3046
  function badgeOf(action, pad2) {
3633
3047
  const name = `${CLASS_MARK[action.class]} ${action.class}`;
3634
3048
  const plain = pad2 ? name.padEnd(BADGE_WIDTH) : name;
3635
3049
  return action.class === "irreversible" ? style.accent(plain) : style.quiet(plain);
3636
3050
  }
3637
3051
  function statusOf(action, pad2) {
3638
- const label = labelFor(action);
3639
- const text = pad2 ? label.padEnd(13) : label;
3052
+ const label = plainly(action).text;
3053
+ const text = pad2 ? label.padEnd(22) : label;
3640
3054
  if (wasRefused(action)) {
3641
3055
  return style.accent(text);
3642
3056
  }
@@ -3675,7 +3089,7 @@ function inverseArgs(inverse) {
3675
3089
  function truncate3(text, limit) {
3676
3090
  return text.length <= limit ? text : `${text.slice(0, limit - 3)}...`;
3677
3091
  }
3678
- function summarise2(actions) {
3092
+ function summarise(actions) {
3679
3093
  const counts = /* @__PURE__ */ new Map();
3680
3094
  for (const action of actions) {
3681
3095
  const label = labelFor(action);
@@ -3869,12 +3283,28 @@ function runGates(journal, asJson) {
3869
3283
  return 0;
3870
3284
  }
3871
3285
  function runDecision(argv, journal, approving) {
3286
+ const unattended = argv.includes("--unattended");
3287
+ if (approving && !process.stdin.isTTY && !unattended) {
3288
+ throw new UsageError(
3289
+ "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.",
3290
+ false
3291
+ );
3292
+ }
3872
3293
  const waiting = journal.listGated();
3873
3294
  const given = positional(argv)[1];
3874
- const by = flag(argv, "--by") ?? process.env["USER"] ?? process.env["LOGNAME"] ?? "unknown";
3295
+ const named = flag(argv, "--by") ?? process.env["USER"] ?? process.env["LOGNAME"] ?? "unknown";
3296
+ const by = approving && unattended ? `${named} (unattended)` : named;
3875
3297
  const reason = flag(argv, "--reason") ?? "denied by operator";
3876
3298
  if (given !== void 0) {
3877
3299
  const settled2 = journal.getAction(given);
3300
+ if (approving && settled2?.status === "denied" && journal.reverseDenial(settled2.id, by)) {
3301
+ out(
3302
+ ` ${style.accent("approved")} ${style.strong(`${settled2.server}.${settled2.tool}`)} ${style.quiet(settled2.id)} ${style.quiet("(was denied)")}`
3303
+ );
3304
+ out("");
3305
+ hint({ why: "the agent can make that call again now, and it will go through" });
3306
+ return 0;
3307
+ }
3878
3308
  if (settled2 !== void 0 && settled2.status !== "gated") {
3879
3309
  process.stderr.write(
3880
3310
  `synartesis: ${given} is no longer awaiting approval (it is ${labelFor(settled2)})
@@ -3891,7 +3321,7 @@ function runDecision(argv, journal, approving) {
3891
3321
  let failed = 0;
3892
3322
  let settled = 0;
3893
3323
  for (const action of targets) {
3894
- const changed = approving ? journal.approve(action.id, by) : journal.deny(action.id, by, reason);
3324
+ const changed = approving ? journal.approve(action.id, by) : journal.denyByPerson(action.id, by, reason);
3895
3325
  if (!changed) {
3896
3326
  const now = journal.getAction(action.id);
3897
3327
  process.stderr.write(
@@ -3911,26 +3341,249 @@ function runDecision(argv, journal, approving) {
3911
3341
  hint(
3912
3342
  firstOf(
3913
3343
  () => 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" }
3344
+ () => approving ? { why: "the agent can make that call again now, and it will go through" } : settled === 1 ? {
3345
+ why: "refused; if the agent asks again it is told you said no. Changed your mind?",
3346
+ run: `approve ${targets[0]?.id.slice(0, 8) ?? ""}`,
3347
+ needs: ["journal"]
3348
+ } : { why: "refused; if the agent asks again it is told you said no" }
3915
3349
  )
3916
3350
  );
3917
3351
  }
3918
3352
  return failed === 0 ? 0 : 1;
3919
3353
  }
3354
+ var ALLOW_MAX_MINUTES = 24 * 60;
3355
+ function allowFor(given) {
3356
+ const found = /^(\d+)(m|h)$/.exec(given.trim());
3357
+ const minutes = found === null ? NaN : Number(found[1]) * (found[2] === "h" ? 60 : 1);
3358
+ if (!Number.isFinite(minutes) || minutes < 1 || minutes > ALLOW_MAX_MINUTES) {
3359
+ throw new UsageError(
3360
+ `--for takes minutes or hours up to a day, like 30m or 2h, not ${given}. For good, use --always.`
3361
+ );
3362
+ }
3363
+ return minutes;
3364
+ }
3365
+ async function runAllow(argv, journalPath) {
3366
+ const given = positional(argv)[1];
3367
+ const forFlag = flag(argv, "--for");
3368
+ const always = argv.includes("--always");
3369
+ const stop = argv.includes("--stop");
3370
+ const now = /* @__PURE__ */ new Date();
3371
+ if (given === void 0) {
3372
+ if (forFlag !== void 0 || always || stop) {
3373
+ throw new UsageError("allow needs the tool, as server.tool -- for example crm.send_email");
3374
+ }
3375
+ const journal = openJournalOrExplain(journalPath);
3376
+ try {
3377
+ const current = journal.listAllowances(now.toISOString());
3378
+ out("");
3379
+ if (current.length === 0) {
3380
+ out(` ${style.quiet("Nothing is being let through without asking.")}`);
3381
+ }
3382
+ for (const one of current) {
3383
+ out(
3384
+ ` ${style.strong(`${one.server}.${one.tool}`)} ${style.quiet(`until ${shortTime(one.until, now)}, allowed by ${one.by}`)}`
3385
+ );
3386
+ }
3387
+ out("");
3388
+ return 0;
3389
+ } finally {
3390
+ journal.close();
3391
+ }
3392
+ }
3393
+ const named = splitQualified(given);
3394
+ if (named === void 0) {
3395
+ throw new UsageError(`${given} is not a tool name; write it as server.tool, for example crm.send_email`);
3396
+ }
3397
+ if ([forFlag !== void 0, always, stop].filter(Boolean).length !== 1) {
3398
+ throw new UsageError(
3399
+ `say how long: allow ${given} --for 1h, allow ${given} --always, or allow ${given} --stop`
3400
+ );
3401
+ }
3402
+ const who = flag(argv, "--by") ?? process.env["USER"] ?? process.env["LOGNAME"] ?? "unknown";
3403
+ if (stop) {
3404
+ const journal = openJournalOrExplain(journalPath);
3405
+ try {
3406
+ const stopped = journal.stopAllowance(named.server, named.tool, who, now.toISOString());
3407
+ out(
3408
+ stopped ? ` ${style.accent("stopped")} ${style.strong(given)} ${style.quiet("is held again from its next call")}` : ` ${style.quiet(`${given} was not being let through`)}`
3409
+ );
3410
+ if (stopped) {
3411
+ out(` ${style.quiet("A rule written with --always stays in the policy; edit it there.")}`);
3412
+ }
3413
+ return 0;
3414
+ } finally {
3415
+ journal.close();
3416
+ }
3417
+ }
3418
+ const unattended = argv.includes("--unattended");
3419
+ const interactive = process.stdin.isTTY;
3420
+ if (!interactive && !unattended) {
3421
+ throw new UsageError(
3422
+ "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.",
3423
+ false
3424
+ );
3425
+ }
3426
+ const by = unattended ? `${who} (unattended)` : who;
3427
+ const manifestPath = findManifest(flag(argv, "--manifest"));
3428
+ const manifest = loadManifest(manifestPath);
3429
+ const spec = manifest.servers[named.server];
3430
+ if (spec === void 0) {
3431
+ const near = didYouMean(named.server, Object.keys(manifest.servers));
3432
+ throw new UsageError(
3433
+ `${manifestPath} has no server called ${named.server}${near === void 0 ? "" : `; did you mean ${near}?`}`
3434
+ );
3435
+ }
3436
+ const { policy } = createPolicyResolver(manifest).resolve(given);
3437
+ if (forFlag !== void 0) {
3438
+ const minutes = allowFor(forFlag);
3439
+ const until = new Date(now.getTime() + minutes * 6e4).toISOString();
3440
+ const journal = openJournalOrExplain(journalPath);
3441
+ try {
3442
+ journal.allow(named.server, named.tool, by, until);
3443
+ } finally {
3444
+ journal.close();
3445
+ }
3446
+ out("");
3447
+ out(
3448
+ ` ${style.accent("allowed")} ${style.strong(given)} ${style.quiet(`until ${shortTime(until, now)} -- its calls go out without asking, and are still recorded`)}`
3449
+ );
3450
+ if (policy.gate !== "always" && policy.gate !== "on_write") {
3451
+ out(` ${style.quiet("(the policy was not holding it anyway)")}`);
3452
+ } else if (policy.class === "irreversible") {
3453
+ out(` ${style.quiet("These cannot be undone. Nothing will ask before each one goes out.")}`);
3454
+ }
3455
+ out(` ${style.quiet(`Takes effect on its next call. To end it sooner: synartesis allow ${given} --stop`)}`);
3456
+ out("");
3457
+ return 0;
3458
+ }
3459
+ const text = readFileSync(manifestPath, "utf8");
3460
+ const pins = manifest.pins?.[named.server];
3461
+ let pin;
3462
+ if (pins !== void 0 && pins[named.tool] === void 0) {
3463
+ const upstream = await startAsTheClientWould(manifestPath, named.server, spec);
3464
+ try {
3465
+ const shape = (await toolShapes(upstream)).find((tool) => tool.name === named.tool);
3466
+ if (shape === void 0) {
3467
+ throw new UsageError(`${named.server} has no tool called ${named.tool}`);
3468
+ }
3469
+ pin = fingerprint(shape.inputSchema);
3470
+ } finally {
3471
+ await upstream.close();
3472
+ }
3473
+ }
3474
+ let edit;
3475
+ try {
3476
+ edit = allowAlways({
3477
+ text,
3478
+ file: manifestPath,
3479
+ server: named.server,
3480
+ tool: named.tool,
3481
+ by,
3482
+ date: now.toISOString().slice(0, 10),
3483
+ ...pin === void 0 ? {} : { pin }
3484
+ });
3485
+ } catch (error) {
3486
+ if (error instanceof PolicyEditError) {
3487
+ process.stderr.write(`synartesis: ${error.message}
3488
+ `);
3489
+ return 1;
3490
+ }
3491
+ throw error;
3492
+ }
3493
+ if (edit.how === "already") {
3494
+ out(` ${style.quiet(`${given} is already let through by ${manifestPath}`)}`);
3495
+ return 0;
3496
+ }
3497
+ if (edit.policy.class === "irreversible") {
3498
+ const confirmed = unattended ? flag(argv, "--confirm") : await ask(` ${given} cannot be undone. Type its name to stop holding it for good: `);
3499
+ if (confirmed?.trim() !== given) {
3500
+ process.stderr.write(`synartesis: not confirmed, so ${manifestPath} was not changed
3501
+ `);
3502
+ return 1;
3503
+ }
3504
+ }
3505
+ if (readFileSync(manifestPath, "utf8") !== text) {
3506
+ process.stderr.write(`synartesis: ${manifestPath} changed while this was running; nothing was written
3507
+ `);
3508
+ return 1;
3509
+ }
3510
+ const beside = `${manifestPath}.${String(process.pid)}.tmp`;
3511
+ writeFileSync(beside, edit.text, { mode: statSync(manifestPath).mode });
3512
+ renameSync(beside, manifestPath);
3513
+ out("");
3514
+ out(
3515
+ ` ${style.accent("allowed")} ${style.strong(given)} ${style.quiet(`for good -- ${edit.how === "added" ? "a rule was added to" : "its rule was changed in"} ${manifestPath}`)}`
3516
+ );
3517
+ if (edit.policy.class === "irreversible") {
3518
+ out(` ${style.quiet("It still cannot be undone: each call is recorded, and none is held.")}`);
3519
+ }
3520
+ if (pin !== void 0) {
3521
+ out(` ${style.quiet("Pinned at the shape it has now, as the rest of the server is.")}`);
3522
+ }
3523
+ out(` ${style.quiet("Takes effect when your client next starts the server. Until then:")}`);
3524
+ out(` ${style.quiet(`synartesis allow ${given} --for 1h`)}`);
3525
+ out("");
3526
+ return 0;
3527
+ }
3528
+ async function ask(question) {
3529
+ const reader = createInterface({ input: process.stdin, output: process.stdout });
3530
+ try {
3531
+ return await reader.question(question);
3532
+ } finally {
3533
+ reader.close();
3534
+ }
3535
+ }
3536
+ function stepWords(kind, dryRun) {
3537
+ switch (kind) {
3538
+ case "revert":
3539
+ return dryRun ? "would put back" : "put back";
3540
+ case "skip":
3541
+ return "nothing to undo";
3542
+ case "already-reverted":
3543
+ return "already undone";
3544
+ case "permanent":
3545
+ return "cannot undo";
3546
+ case "kept":
3547
+ return "left alone";
3548
+ case "halt":
3549
+ return "stopped";
3550
+ }
3551
+ }
3552
+ function resultWords(status, dryRun) {
3553
+ switch (status) {
3554
+ case "rolled_back":
3555
+ return dryRun ? "would all be undone (nothing was written)" : "all undone";
3556
+ case "partial":
3557
+ return dryRun ? "would be partly undone (nothing was written)" : "partly undone";
3558
+ }
3559
+ }
3920
3560
  function report(result, alreadyForcing = false, as = "") {
3921
3561
  out("");
3922
3562
  out(` ${style.label(result.dryRun ? "dry run" : "undo")} ${style.strong(result.runId)}`);
3923
3563
  out(` ${rule(72)}`);
3924
3564
  out("");
3925
3565
  let separated = false;
3566
+ let reads = 0;
3567
+ const flushReads = () => {
3568
+ if (reads > 0) {
3569
+ out(` ${style.quiet(`${String(reads)} read${reads === 1 ? "" : "s"}, nothing to undo`)}`);
3570
+ reads = 0;
3571
+ }
3572
+ };
3926
3573
  for (const step of result.steps) {
3574
+ if (step.kind === "skip" && step.reason === "readonly") {
3575
+ reads += 1;
3576
+ continue;
3577
+ }
3578
+ flushReads();
3927
3579
  if (step.kind === "kept" && !separated) {
3928
3580
  separated = true;
3929
3581
  out("");
3930
3582
  out(` ${style.quiet("left alone")}`);
3931
3583
  }
3932
3584
  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);
3585
+ const said = stepWords(step.kind, result.dryRun);
3586
+ const kind = step.kind === "halt" || step.kind === "permanent" ? style.accent(said.padEnd(16)) : said.padEnd(16);
3934
3587
  out(
3935
3588
  ` ${style.quiet(String(step.seq).padStart(3))} ${kind} ${style.strong(`${step.server}.${step.tool}`)} ${style.quiet(step.reason)}${unverified}`
3936
3589
  );
@@ -3950,6 +3603,7 @@ function report(result, alreadyForcing = false, as = "") {
3950
3603
  );
3951
3604
  }
3952
3605
  }
3606
+ flushReads();
3953
3607
  if (result.halted !== void 0) {
3954
3608
  const halt = result.halted;
3955
3609
  out("");
@@ -3993,8 +3647,9 @@ function report(result, alreadyForcing = false, as = "") {
3993
3647
  );
3994
3648
  }
3995
3649
  out("");
3650
+ const outcome = resultWords(result.status, result.dryRun);
3996
3651
  out(
3997
- ` ${style.label("result")} ${result.status === "rolled_back" ? result.status : style.accent(result.status)}`
3652
+ ` ${style.label("result")} ${result.status === "rolled_back" ? outcome : style.accent(outcome)}`
3998
3653
  );
3999
3654
  out("");
4000
3655
  if (result.dryRun && result.status === "rolled_back") {
@@ -4006,7 +3661,7 @@ function report(result, alreadyForcing = false, as = "") {
4006
3661
  }
4007
3662
  return result.halted === void 0 && permanent.length === 0 ? 0 : 1;
4008
3663
  }
4009
- async function withUpstreams(manifestPath, use, only) {
3664
+ async function withUpstreams(manifestPath, use, only, session) {
4010
3665
  const manifest = loadManifest(manifestPath);
4011
3666
  const upstreams = [];
4012
3667
  const missing = [];
@@ -4016,15 +3671,7 @@ async function withUpstreams(manifestPath, use, only) {
4016
3671
  continue;
4017
3672
  }
4018
3673
  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
- );
3674
+ upstreams.push(await startAsTheClientWould(manifestPath, name, spec, session));
4028
3675
  } catch (error) {
4029
3676
  missing.push(`${name}: ${describe(error)}`);
4030
3677
  }
@@ -4077,7 +3724,8 @@ async function performUndo(manifestPath, journal, runId, options) {
4077
3724
  }),
4078
3725
  // A replan re-resolves inverses from the current policy, which may name a
4079
3726
  // server this run never used; everything else needs only what it touched.
4080
- options.replan === true ? void 0 : serversUsedBy(journal, runId)
3727
+ options.replan === true ? void 0 : serversUsedBy(journal, runId),
3728
+ { journal, runId }
4081
3729
  );
4082
3730
  }
4083
3731
  async function runUndo(argv, journal) {
@@ -4087,9 +3735,16 @@ async function runUndo(argv, journal) {
4087
3735
  throw new UsageError("--to needs a positive whole number", false);
4088
3736
  }
4089
3737
  const given = positional(argv)[1];
4090
- const chosen = pick([...journal.listRuns()].reverse(), given, RUN, true);
3738
+ const runs = [...journal.listRuns()].reverse();
3739
+ const standing3 = journal.standingPerRun();
3740
+ const hasWork = (id) => {
3741
+ const here = standing3.get(id);
3742
+ return here !== void 0 && (here.undoable > 0 || here.conflicted > 0);
3743
+ };
3744
+ const newestWithWork = given === void 0 ? runs.find((run) => hasWork(run.id)) : void 0;
3745
+ const chosen = newestWithWork ?? pick(runs, given, RUN, true);
4091
3746
  const runId = chosen.id;
4092
- if (journal.getRun(runId)?.status === "active" && !argv.includes("--yes") && !argv.includes("--dry-run")) {
3747
+ if (journal.getRun(runId)?.status === "active" && hasWork(runId) && !argv.includes("--yes") && !argv.includes("--dry-run")) {
4093
3748
  throw new UsageError(
4094
3749
  `${runId.slice(0, 8)} has not ended, so an agent may still be writing to it.
4095
3750
  See what an undo would do: ${cliCommand()} undo ${runId.slice(0, 8)} --dry-run
@@ -4105,7 +3760,9 @@ async function runUndo(argv, journal) {
4105
3760
  ).length;
4106
3761
  out("");
4107
3762
  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()}`)
3763
+ ` ${style.quiet(
3764
+ newestWithWork === void 0 ? "no session named, so the most recent:" : "no session named, so the most recent with something to undo:"
3765
+ )} ${style.strong(runId.slice(0, 8))} ` + style.quiet(`${chosen.label ?? "an agent"}, ${shortTime(chosen.startedAt).trim()}`)
4109
3766
  );
4110
3767
  if (left === 0) {
4111
3768
  out("");
@@ -4155,7 +3812,8 @@ async function runUndo(argv, journal) {
4155
3812
  const over = (await withUpstreams(
4156
3813
  manifestPath,
4157
3814
  async (router) => await inspect({ journal, router, runId }),
4158
- serversUsedBy(journal, runId)
3815
+ serversUsedBy(journal, runId),
3816
+ { journal, runId }
4159
3817
  )).resources.filter(
4160
3818
  // Below --to nothing is undone, so a change down there is not something
4161
3819
  // this command would write over and must not stand in its way.
@@ -4217,7 +3875,9 @@ var KNOWN_COMMANDS = [
4217
3875
  "watch",
4218
3876
  "approve",
4219
3877
  "deny",
3878
+ "allow",
4220
3879
  "resolve",
3880
+ "notify",
4221
3881
  "undo",
4222
3882
  "help",
4223
3883
  "version"
@@ -4247,6 +3907,18 @@ var FLAGS = /* @__PURE__ */ new Set([
4247
3907
  "--force",
4248
3908
  "--yes",
4249
3909
  "--older-than",
3910
+ // install: cover hosted servers too, through mcp-remote.
3911
+ "--remote",
3912
+ // notify: send one to see whether they reach you.
3913
+ "--test",
3914
+ // approve without a terminal, from a script; recorded as unattended.
3915
+ "--unattended",
3916
+ // allow: for a while, for good, or no longer; and the typed confirmation
3917
+ // for a tool that cannot be undone, given without a terminal.
3918
+ "--for",
3919
+ "--always",
3920
+ "--stop",
3921
+ "--confirm",
4250
3922
  "--help",
4251
3923
  "-h",
4252
3924
  "--version",
@@ -4255,8 +3927,8 @@ var FLAGS = /* @__PURE__ */ new Set([
4255
3927
  ]);
4256
3928
  function version() {
4257
3929
  try {
4258
- const root = dirname3(fileURLToPath2(import.meta.url));
4259
- const parsed = JSON.parse(readFileSync4(join3(root, "..", "package.json"), "utf8"));
3930
+ const root = dirname(fileURLToPath(import.meta.url));
3931
+ const parsed = JSON.parse(readFileSync(join2(root, "..", "package.json"), "utf8"));
4260
3932
  const found = typeof parsed === "object" && parsed !== null ? parsed.version : void 0;
4261
3933
  return typeof found === "string" ? found : "unknown";
4262
3934
  } catch {
@@ -4287,7 +3959,7 @@ function rejectUnknownFlags(argv) {
4287
3959
  }
4288
3960
  }
4289
3961
  function openJournalOrExplain(journalPath) {
4290
- if (!existsSync8(journalPath)) {
3962
+ if (!existsSync5(journalPath)) {
4291
3963
  throw new UsageError(
4292
3964
  `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
3965
  );
@@ -4317,8 +3989,8 @@ ${COMMANDS}`);
4317
3989
  }
4318
3990
  const givenJournal = flag(argv, "--journal");
4319
3991
  const givenManifest = flag(argv, "--manifest");
4320
- journalArg = givenJournal === void 0 ? "" : ` --journal ${resolve3(givenJournal)}`;
4321
- manifestArg = givenManifest === void 0 ? "" : ` --manifest ${resolve3(givenManifest)}`;
3992
+ journalArg = givenJournal === void 0 ? "" : ` --journal ${resolve(givenJournal)}`;
3993
+ manifestArg = givenManifest === void 0 ? "" : ` --manifest ${resolve(givenManifest)}`;
4322
3994
  if (command === void 0) {
4323
3995
  const manifestPath = findManifest(flag(argv, "--manifest"));
4324
3996
  const journalPath2 = findJournal(flag(argv, "--journal"), manifestPath);
@@ -4335,7 +4007,8 @@ ${COMMANDS}`);
4335
4007
  return await withUpstreams(
4336
4008
  manifestPath,
4337
4009
  async (router) => await inspect({ journal: journal2, router, runId }),
4338
- serversUsedBy(journal2, runId)
4010
+ serversUsedBy(journal2, runId),
4011
+ { journal: journal2, runId }
4339
4012
  );
4340
4013
  } finally {
4341
4014
  journal2.close();
@@ -4387,7 +4060,7 @@ ${COMMANDS}`);
4387
4060
  decideAs: flag(argv, "--by") ?? process.env["USER"] ?? process.env["LOGNAME"] ?? "unknown"
4388
4061
  });
4389
4062
  }
4390
- if (!existsSync8(journalPath) && (command === "list" || command === "show" || command === "gates")) {
4063
+ if (!existsSync5(journalPath) && (command === "list" || command === "show" || command === "gates")) {
4391
4064
  if (asJson) {
4392
4065
  out(JSON.stringify(command === "show" ? { run: null, actions: [] } : []));
4393
4066
  return 0;
@@ -4401,6 +4074,12 @@ ${COMMANDS}`);
4401
4074
  out("");
4402
4075
  return 0;
4403
4076
  }
4077
+ if (command === "notify") {
4078
+ return runNotifyTest(argv);
4079
+ }
4080
+ if (command === "allow") {
4081
+ return await runAllow(argv, journalPath);
4082
+ }
4404
4083
  if (command === "desktop") {
4405
4084
  return runDesktop();
4406
4085
  }
@@ -4432,6 +4111,32 @@ ${COMMANDS}`);
4432
4111
  journal.close();
4433
4112
  }
4434
4113
  }
4114
+ function runNotifyTest(argv) {
4115
+ if (!argv.includes("--test")) {
4116
+ throw new UsageError("notify takes --test, which sends one to see whether they reach you");
4117
+ }
4118
+ const why = canNotify();
4119
+ if (why !== void 0) {
4120
+ out(` ${style.quiet(`No notification sent: ${why}.`)}`);
4121
+ return 1;
4122
+ }
4123
+ desktopNotifier()({
4124
+ server: "synartesis",
4125
+ tool: "test",
4126
+ actionId: "00000000",
4127
+ approve: "this is only a test"
4128
+ });
4129
+ out("");
4130
+ out(` ${style.quiet("Sent one. If nothing appeared, notifications for it are switched off:")}`);
4131
+ out(
4132
+ ` ${style.quiet(
4133
+ 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."
4134
+ )}`
4135
+ );
4136
+ out(` ${style.quiet("Either way, synartesis watch shows every held call as it happens.")}`);
4137
+ out("");
4138
+ return 0;
4139
+ }
4435
4140
  function runDesktop() {
4436
4141
  const found = findDesktop();
4437
4142
  if (found === void 0) {