synartesis 0.1.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/LICENSE +21 -0
- package/README.md +501 -0
- package/dist/chunk-K3QIPVBY.js +85 -0
- package/dist/chunk-K3QIPVBY.js.map +1 -0
- package/dist/chunk-X4VQNEP5.js +1383 -0
- package/dist/chunk-X4VQNEP5.js.map +1 -0
- package/dist/cli.js +1708 -0
- package/dist/cli.js.map +1 -0
- package/dist/demo-agent.js +67 -0
- package/dist/demo-agent.js.map +1 -0
- package/dist/proxy.js +913 -0
- package/dist/proxy.js.map +1 -0
- package/dist/toy-crm.js +293 -0
- package/dist/toy-crm.js.map +1 -0
- package/manifests/filesystem.yaml +97 -0
- package/manifests/git.yaml +77 -0
- package/manifests/github.yaml +175 -0
- package/manifests/memory.yaml +96 -0
- package/manifests/toy-crm.yaml +66 -0
- package/package.json +67 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1708 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
WORDMARK,
|
|
4
|
+
banner,
|
|
5
|
+
canonical,
|
|
6
|
+
cliCommand,
|
|
7
|
+
connectStdioUpstream,
|
|
8
|
+
createPolicyResolver,
|
|
9
|
+
createRouter,
|
|
10
|
+
findJournal,
|
|
11
|
+
findManifest,
|
|
12
|
+
labelFor,
|
|
13
|
+
loadManifest,
|
|
14
|
+
observeState,
|
|
15
|
+
openJournal,
|
|
16
|
+
parseManifest,
|
|
17
|
+
planInverse,
|
|
18
|
+
planRead,
|
|
19
|
+
proxyCommand,
|
|
20
|
+
qualify,
|
|
21
|
+
rule,
|
|
22
|
+
style,
|
|
23
|
+
toPayload,
|
|
24
|
+
verifyAgainstServers,
|
|
25
|
+
wasRefused
|
|
26
|
+
} from "./chunk-X4VQNEP5.js";
|
|
27
|
+
import {
|
|
28
|
+
DriftConflict,
|
|
29
|
+
ManifestError,
|
|
30
|
+
RollbackHalted,
|
|
31
|
+
SynartesisError,
|
|
32
|
+
UpstreamError,
|
|
33
|
+
describe
|
|
34
|
+
} from "./chunk-K3QIPVBY.js";
|
|
35
|
+
|
|
36
|
+
// src/cli.ts
|
|
37
|
+
import { existsSync as existsSync3, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
38
|
+
import { dirname, resolve } from "path";
|
|
39
|
+
|
|
40
|
+
// src/init/draft.ts
|
|
41
|
+
import { z } from "zod";
|
|
42
|
+
var toolSchema = z.looseObject({
|
|
43
|
+
name: z.string(),
|
|
44
|
+
description: z.string().optional(),
|
|
45
|
+
annotations: z.looseObject({
|
|
46
|
+
readOnlyHint: z.boolean().optional(),
|
|
47
|
+
destructiveHint: z.boolean().optional(),
|
|
48
|
+
idempotentHint: z.boolean().optional()
|
|
49
|
+
}).optional()
|
|
50
|
+
});
|
|
51
|
+
var listSchema = z.looseObject({
|
|
52
|
+
tools: z.array(toolSchema),
|
|
53
|
+
nextCursor: z.string().optional()
|
|
54
|
+
});
|
|
55
|
+
function quote(value) {
|
|
56
|
+
return JSON.stringify(value);
|
|
57
|
+
}
|
|
58
|
+
function summarise(text) {
|
|
59
|
+
if (text === void 0) {
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
const single = text.replace(/\s+/g, " ").trim();
|
|
63
|
+
return single.length > 96 ? `${single.slice(0, 93)}...` : single;
|
|
64
|
+
}
|
|
65
|
+
function draftTool(server, tool) {
|
|
66
|
+
const match = `${server}.${tool.name}`;
|
|
67
|
+
const lines = [];
|
|
68
|
+
const description = summarise(tool.description);
|
|
69
|
+
if (description !== "") {
|
|
70
|
+
lines.push(` # ${description}`);
|
|
71
|
+
}
|
|
72
|
+
if (tool.annotations?.readOnlyHint === true) {
|
|
73
|
+
lines.push(` # classified readonly from the server's readOnlyHint; verify it before relying on it.`);
|
|
74
|
+
lines.push(` - match: ${quote(match)}`);
|
|
75
|
+
lines.push(` class: readonly`);
|
|
76
|
+
return lines.join("\n");
|
|
77
|
+
}
|
|
78
|
+
lines.push(` # TODO: this is gated on every call until you describe how to undo it.`);
|
|
79
|
+
lines.push(` # reversible needs a snapshot (a pre-read) and an inverse.`);
|
|
80
|
+
lines.push(` # compensable needs an inverse only, usually built from $result.`);
|
|
81
|
+
lines.push(` # irreversible is correct when neither exists; leave gate: always.`);
|
|
82
|
+
lines.push(` - match: ${quote(match)}`);
|
|
83
|
+
lines.push(` class: irreversible`);
|
|
84
|
+
lines.push(` gate: always`);
|
|
85
|
+
return lines.join("\n");
|
|
86
|
+
}
|
|
87
|
+
async function draftManifest(options) {
|
|
88
|
+
const upstream = await connectStdioUpstream({
|
|
89
|
+
name: options.name,
|
|
90
|
+
command: options.command,
|
|
91
|
+
args: options.args,
|
|
92
|
+
stderr: "capture"
|
|
93
|
+
});
|
|
94
|
+
let tools;
|
|
95
|
+
try {
|
|
96
|
+
const collected = [];
|
|
97
|
+
let cursor;
|
|
98
|
+
do {
|
|
99
|
+
const page = listSchema.parse(
|
|
100
|
+
await upstream.client.request(
|
|
101
|
+
{ method: "tools/list", params: cursor === void 0 ? {} : { cursor } },
|
|
102
|
+
z.looseObject({})
|
|
103
|
+
)
|
|
104
|
+
);
|
|
105
|
+
collected.push(...page.tools);
|
|
106
|
+
cursor = page.nextCursor;
|
|
107
|
+
} while (cursor !== void 0);
|
|
108
|
+
tools = collected;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw new UpstreamError(options.name, "tools/list", error);
|
|
111
|
+
} finally {
|
|
112
|
+
await upstream.close();
|
|
113
|
+
}
|
|
114
|
+
if (tools.length === 0) {
|
|
115
|
+
throw new ManifestError(`${options.name} exposes no tools, so there is no policy to write`);
|
|
116
|
+
}
|
|
117
|
+
const existing = options.existing?.trimEnd();
|
|
118
|
+
if (existing !== void 0 && existing.includes(`
|
|
119
|
+
${options.name}:`)) {
|
|
120
|
+
throw new ManifestError(
|
|
121
|
+
`${options.name} is already declared in the manifest; remove it first or choose another name`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const server = [
|
|
125
|
+
` ${options.name}:`,
|
|
126
|
+
` command: ${quote(options.command)}`,
|
|
127
|
+
` args: [${options.args.map(quote).join(", ")}]`
|
|
128
|
+
].join("\n");
|
|
129
|
+
const policies = tools.map((tool) => draftTool(options.name, tool)).join("\n\n");
|
|
130
|
+
if (existing === void 0) {
|
|
131
|
+
return [
|
|
132
|
+
`# Generated by synartesis init from ${options.name}'s tools/list.`,
|
|
133
|
+
`# Every tool starts gated. Working through the TODOs is the whole job:`,
|
|
134
|
+
`# a tool with no inverse is one an agent cannot use unsupervised.`,
|
|
135
|
+
``,
|
|
136
|
+
`version: 1`,
|
|
137
|
+
``,
|
|
138
|
+
`servers:`,
|
|
139
|
+
server,
|
|
140
|
+
``,
|
|
141
|
+
`tools:`,
|
|
142
|
+
policies,
|
|
143
|
+
``
|
|
144
|
+
].join("\n");
|
|
145
|
+
}
|
|
146
|
+
return mergeInto(existing, server, policies, options.name);
|
|
147
|
+
}
|
|
148
|
+
function mergeInto(existing, server, policies, name) {
|
|
149
|
+
const serversAt = existing.indexOf("\nservers:");
|
|
150
|
+
const toolsAt = existing.indexOf("\ntools:");
|
|
151
|
+
if (serversAt === -1 || toolsAt === -1 || toolsAt < serversAt) {
|
|
152
|
+
throw new ManifestError(
|
|
153
|
+
"the existing manifest does not have a servers: block followed by a tools: block, so it cannot be extended automatically"
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
const head = existing.slice(0, toolsAt);
|
|
157
|
+
const tail = existing.slice(toolsAt);
|
|
158
|
+
return [
|
|
159
|
+
head.trimEnd(),
|
|
160
|
+
server,
|
|
161
|
+
tail.trimEnd(),
|
|
162
|
+
``,
|
|
163
|
+
` # --- added by synartesis init for ${name} ---`,
|
|
164
|
+
policies,
|
|
165
|
+
``
|
|
166
|
+
].join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/rollback/rollback.ts
|
|
170
|
+
import { z as z2 } from "zod";
|
|
171
|
+
var IDEMPOTENCY_META_KEY = "synartesis.dev/idempotency-key";
|
|
172
|
+
var inversePlan = z2.object({
|
|
173
|
+
server: z2.string(),
|
|
174
|
+
tool: z2.string(),
|
|
175
|
+
args: z2.record(z2.string(), z2.unknown())
|
|
176
|
+
});
|
|
177
|
+
var observation = z2.union([
|
|
178
|
+
z2.object({ present: z2.literal(true), value: z2.unknown() }),
|
|
179
|
+
z2.object({ present: z2.literal(false) })
|
|
180
|
+
]);
|
|
181
|
+
var toolResult = z2.looseObject({ isError: z2.boolean().default(false) });
|
|
182
|
+
function sameState(a, b) {
|
|
183
|
+
return canonical(a) === canonical(b);
|
|
184
|
+
}
|
|
185
|
+
function classify(action, replanning) {
|
|
186
|
+
switch (action.status) {
|
|
187
|
+
case "rolled_back":
|
|
188
|
+
return { kind: "already-reverted", reason: "already rolled back", verified: true };
|
|
189
|
+
case "failed":
|
|
190
|
+
case "denied":
|
|
191
|
+
return { kind: "skip", reason: `never applied (${action.status})`, verified: true };
|
|
192
|
+
case "pending":
|
|
193
|
+
return {
|
|
194
|
+
kind: "halt",
|
|
195
|
+
reason: "outcome unknown: the process died mid-call, so whether this applied cannot be determined",
|
|
196
|
+
verified: false
|
|
197
|
+
};
|
|
198
|
+
case "gated":
|
|
199
|
+
return { kind: "skip", reason: "never applied (awaiting approval)", verified: true };
|
|
200
|
+
case "approved":
|
|
201
|
+
return { kind: "skip", reason: "never applied (approved, never retried)", verified: true };
|
|
202
|
+
case "unrecoverable":
|
|
203
|
+
if (action.inverse === void 0) {
|
|
204
|
+
return void 0;
|
|
205
|
+
}
|
|
206
|
+
return replanning ? void 0 : { kind: "halt", reason: "halted here on an earlier attempt", verified: false };
|
|
207
|
+
case "applied":
|
|
208
|
+
case "rolling_back":
|
|
209
|
+
return void 0;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async function rollback(options) {
|
|
213
|
+
const { journal, router, runId } = options;
|
|
214
|
+
const dryRun = options.dryRun ?? false;
|
|
215
|
+
const signal = options.signal ?? new AbortController().signal;
|
|
216
|
+
const policies = options.replanWith === void 0 ? void 0 : createPolicyResolver(options.replanWith);
|
|
217
|
+
const replan = (action) => {
|
|
218
|
+
if (policies === void 0) {
|
|
219
|
+
return {};
|
|
220
|
+
}
|
|
221
|
+
const policy = policies.resolve(qualify(action.server, action.tool)).policy;
|
|
222
|
+
const context = {
|
|
223
|
+
args: action.args,
|
|
224
|
+
...action.snapshot === void 0 ? {} : { snapshot: action.snapshot },
|
|
225
|
+
...action.result === void 0 ? {} : { result: toPayload(action.result) }
|
|
226
|
+
};
|
|
227
|
+
try {
|
|
228
|
+
return {
|
|
229
|
+
...policy.inverse === void 0 ? {} : { inverse: planInverse(policy.inverse, context) },
|
|
230
|
+
...policy.snapshot === void 0 ? {} : { verify: planRead(policy.snapshot, { args: action.args }) }
|
|
231
|
+
};
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return { error: describe(error) };
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
const all = journal.getActions(runId);
|
|
237
|
+
const inScope = [...all].filter((action) => options.toSeq === void 0 || action.seq >= options.toSeq).sort((a, b) => b.seq - a.seq);
|
|
238
|
+
const steps = [];
|
|
239
|
+
let halted;
|
|
240
|
+
let leftInPlace = false;
|
|
241
|
+
for (const action of inScope) {
|
|
242
|
+
const early = classify(action, policies !== void 0);
|
|
243
|
+
if (early?.kind === "halt") {
|
|
244
|
+
const seen = action.error ?? "";
|
|
245
|
+
const detail = action.status === "unrecoverable" && seen !== "" ? `what it saw when it halted, which may no longer hold:
|
|
246
|
+
${seen}
|
|
247
|
+
Resolve the conflict, then run undo --replan to check it against the world as it is now.` : seen;
|
|
248
|
+
halted = { seq: action.seq, reason: early.reason, detail };
|
|
249
|
+
steps.push({ ...describeStep(action), ...early });
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
if (early !== void 0) {
|
|
253
|
+
steps.push({ ...describeStep(action), ...early });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (action.class === "readonly") {
|
|
257
|
+
steps.push({ ...describeStep(action), kind: "skip", reason: "readonly", verified: true });
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const rebuilt = replan(action);
|
|
261
|
+
const parsedPlan = inversePlan.safeParse(rebuilt.inverse ?? action.inverse);
|
|
262
|
+
if (!parsedPlan.success) {
|
|
263
|
+
const approved = action.approvedBy === void 0 ? "" : `, approved by ${action.approvedBy}`;
|
|
264
|
+
const reason = action.class === "irreversible" ? `cannot be undone${approved}; left in place` : `no usable inverse was recorded${action.error === void 0 ? "" : `: ${action.error}`}; left in place`;
|
|
265
|
+
steps.push({ ...describeStep(action), kind: "permanent", reason, verified: false });
|
|
266
|
+
leftInPlace = true;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const plan = parsedPlan.data;
|
|
270
|
+
const verifyRead = inversePlan.safeParse(rebuilt.verify ?? action.verify);
|
|
271
|
+
const recordedPost = observation.safeParse(action.postSnapshot);
|
|
272
|
+
let verified = false;
|
|
273
|
+
if (recordedPost.success && verifyRead.success) {
|
|
274
|
+
let current;
|
|
275
|
+
try {
|
|
276
|
+
current = await observeState(router, verifyRead.data, signal);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
const reason = `could not read current state to check for drift: ${describe(error)}`;
|
|
279
|
+
halted = { seq: action.seq, reason, detail: "" };
|
|
280
|
+
steps.push({ ...describeStep(action), kind: "halt", reason, verified: false });
|
|
281
|
+
if (!dryRun) {
|
|
282
|
+
journal.markUnrecoverable(action.id, reason);
|
|
283
|
+
}
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
if (sameState(current, recordedPost.data)) {
|
|
287
|
+
verified = true;
|
|
288
|
+
} else if (sameState(current, intendedAfterInverse(action))) {
|
|
289
|
+
steps.push({
|
|
290
|
+
...describeStep(action),
|
|
291
|
+
kind: "already-reverted",
|
|
292
|
+
reason: "the resource is already in the state this inverse would produce",
|
|
293
|
+
verified: true,
|
|
294
|
+
plan
|
|
295
|
+
});
|
|
296
|
+
if (!dryRun) {
|
|
297
|
+
journal.markRolledBack(action.id);
|
|
298
|
+
}
|
|
299
|
+
continue;
|
|
300
|
+
} else {
|
|
301
|
+
const conflict = new DriftConflict(action.seq, recordedPost.data, current);
|
|
302
|
+
halted = { seq: action.seq, reason: "drift detected", detail: conflict.message };
|
|
303
|
+
steps.push({
|
|
304
|
+
...describeStep(action),
|
|
305
|
+
kind: "halt",
|
|
306
|
+
reason: "drift detected",
|
|
307
|
+
verified: false,
|
|
308
|
+
plan
|
|
309
|
+
});
|
|
310
|
+
if (!dryRun) {
|
|
311
|
+
journal.markUnrecoverable(action.id, conflict.message);
|
|
312
|
+
}
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (!verified && action.status === "rolling_back") {
|
|
317
|
+
const reason = "an inverse was already sent before an interruption and no pre-read is declared, so whether it applied cannot be determined";
|
|
318
|
+
halted = { seq: action.seq, reason, detail: action.error ?? "" };
|
|
319
|
+
steps.push({ ...describeStep(action), kind: "halt", reason, verified: false, plan });
|
|
320
|
+
if (!dryRun) {
|
|
321
|
+
journal.markUnrecoverable(action.id, reason);
|
|
322
|
+
}
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
steps.push({
|
|
326
|
+
...describeStep(action),
|
|
327
|
+
kind: "revert",
|
|
328
|
+
reason: verified ? "state matches; applying inverse" : unverifiedBecause(action),
|
|
329
|
+
verified,
|
|
330
|
+
plan,
|
|
331
|
+
...rebuilt.inverse === void 0 ? {} : { replanned: true }
|
|
332
|
+
});
|
|
333
|
+
if (dryRun) {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const claimed = journal.markRollingBack(action.id);
|
|
337
|
+
if (!claimed && action.status === "applied") {
|
|
338
|
+
const reason = "another undo is already working on this action";
|
|
339
|
+
halted = { seq: action.seq, reason, detail: "" };
|
|
340
|
+
steps[steps.length - 1] = {
|
|
341
|
+
...describeStep(action),
|
|
342
|
+
kind: "halt",
|
|
343
|
+
reason,
|
|
344
|
+
verified,
|
|
345
|
+
plan
|
|
346
|
+
};
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
const outcome = await executeInverse(router, plan, action.idempotencyKey, signal);
|
|
350
|
+
if (outcome.ok) {
|
|
351
|
+
journal.markRolledBack(action.id);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const halt = new RollbackHalted(action.seq, outcome.message);
|
|
355
|
+
if (outcome.rejected) {
|
|
356
|
+
journal.markInverseRejected(action.id, halt.message);
|
|
357
|
+
} else {
|
|
358
|
+
journal.markUnknownInverse(action.id, halt.message);
|
|
359
|
+
}
|
|
360
|
+
halted = { seq: action.seq, reason: "the inverse failed", detail: halt.message };
|
|
361
|
+
steps[steps.length - 1] = {
|
|
362
|
+
...describeStep(action),
|
|
363
|
+
kind: "halt",
|
|
364
|
+
reason: "the inverse failed",
|
|
365
|
+
verified,
|
|
366
|
+
plan
|
|
367
|
+
};
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
const completedWholeRun = halted === void 0 && !leftInPlace && options.toSeq === void 0;
|
|
371
|
+
const status = completedWholeRun ? "rolled_back" : "partial";
|
|
372
|
+
if (!dryRun) {
|
|
373
|
+
journal.endRun(runId, status);
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
runId,
|
|
377
|
+
status,
|
|
378
|
+
dryRun,
|
|
379
|
+
steps,
|
|
380
|
+
...halted === void 0 ? {} : { halted }
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
function unverifiedBecause(action) {
|
|
384
|
+
return action.verify === void 0 ? "no pre-read declared, so drift could not be ruled out" : "the post-state was never captured, so drift could not be ruled out";
|
|
385
|
+
}
|
|
386
|
+
function describeStep(action) {
|
|
387
|
+
return { seq: action.seq, server: action.server, tool: action.tool };
|
|
388
|
+
}
|
|
389
|
+
function intendedAfterInverse(action) {
|
|
390
|
+
return action.snapshot === void 0 ? void 0 : { present: true, value: action.snapshot };
|
|
391
|
+
}
|
|
392
|
+
async function executeInverse(router, plan, idempotencyKey, signal) {
|
|
393
|
+
const upstream = router.byName(plan.server);
|
|
394
|
+
if (upstream === void 0) {
|
|
395
|
+
return { ok: false, rejected: false, message: `server ${plan.server} is not connected` };
|
|
396
|
+
}
|
|
397
|
+
let raw;
|
|
398
|
+
try {
|
|
399
|
+
raw = await upstream.client.request(
|
|
400
|
+
{
|
|
401
|
+
method: "tools/call",
|
|
402
|
+
params: {
|
|
403
|
+
name: plan.tool,
|
|
404
|
+
arguments: plan.args,
|
|
405
|
+
_meta: { [IDEMPOTENCY_META_KEY]: idempotencyKey }
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
z2.looseObject({}),
|
|
409
|
+
{ signal }
|
|
410
|
+
);
|
|
411
|
+
} catch (error) {
|
|
412
|
+
return { ok: false, rejected: false, message: describe(error) };
|
|
413
|
+
}
|
|
414
|
+
const parsed = toolResult.safeParse(raw);
|
|
415
|
+
if (parsed.success && parsed.data.isError) {
|
|
416
|
+
return {
|
|
417
|
+
ok: false,
|
|
418
|
+
rejected: true,
|
|
419
|
+
message: `the inverse was refused: ${JSON.stringify(raw)}`
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
return { ok: true };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// src/watch.ts
|
|
426
|
+
import { existsSync } from "fs";
|
|
427
|
+
|
|
428
|
+
// src/keys.ts
|
|
429
|
+
var SEQUENCE = /^\u001b(\[[0-9;?]*[ -\/]*[@-~]|O[@-~])/;
|
|
430
|
+
function keysIn(chunk) {
|
|
431
|
+
const keys = [];
|
|
432
|
+
let at = 0;
|
|
433
|
+
while (at < chunk.length) {
|
|
434
|
+
const rest = chunk.slice(at);
|
|
435
|
+
const sequence = rest.startsWith("\x1B") ? SEQUENCE.exec(rest) : null;
|
|
436
|
+
const key = sequence?.[0] ?? rest.slice(0, 1);
|
|
437
|
+
keys.push(key);
|
|
438
|
+
at += key.length;
|
|
439
|
+
}
|
|
440
|
+
return keys;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// src/watch.ts
|
|
444
|
+
var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
445
|
+
var MARK = {
|
|
446
|
+
readonly: "\xB7",
|
|
447
|
+
reversible: "\u2190",
|
|
448
|
+
compensable: "\u2248",
|
|
449
|
+
irreversible: "!",
|
|
450
|
+
unclassified: "?"
|
|
451
|
+
};
|
|
452
|
+
var NOTICE_TICKS = 26;
|
|
453
|
+
function line(action) {
|
|
454
|
+
const mark = MARK[action.class] ?? "?";
|
|
455
|
+
const badge = `${mark} ${action.class}`.padEnd(14);
|
|
456
|
+
const when = action.ts.slice(11, 19);
|
|
457
|
+
const label = labelFor(action).padEnd(13);
|
|
458
|
+
const status = action.status === "gated" ? style.strong(label) : wasRefused(action) ? style.accent(label) : style.quiet(label);
|
|
459
|
+
return ` ${style.quiet(when)} ${style.quiet(badge)} ${status} ${action.server}.${action.tool}`;
|
|
460
|
+
}
|
|
461
|
+
function waitingForJournal(options, tick) {
|
|
462
|
+
const spinner = options.live ? `${style.accent(FRAMES[tick % FRAMES.length] ?? "")} ` : "";
|
|
463
|
+
return [
|
|
464
|
+
"",
|
|
465
|
+
` ${style.plate(WORDMARK)} ${style.quiet(options.journalPath)}`,
|
|
466
|
+
` ${rule(64)}`,
|
|
467
|
+
"",
|
|
468
|
+
` ${spinner}${style.quiet("no journal here yet")}`,
|
|
469
|
+
"",
|
|
470
|
+
` ${style.quiet("One appears the first time an agent calls a tool through the proxy.")}`,
|
|
471
|
+
` ${style.quiet("Point your client at it, then work as usual; this will fill in.")}`,
|
|
472
|
+
""
|
|
473
|
+
].join("\n");
|
|
474
|
+
}
|
|
475
|
+
function render(journal, options, tick, view) {
|
|
476
|
+
const runs = journal.listRuns();
|
|
477
|
+
const recent = journal.recentActions(12);
|
|
478
|
+
const waiting = journal.listGated();
|
|
479
|
+
const active = runs.filter((run) => run.status === "active").length;
|
|
480
|
+
const out2 = [];
|
|
481
|
+
out2.push("");
|
|
482
|
+
out2.push(` ${style.plate(WORDMARK)} ${style.quiet(options.journalPath)}`);
|
|
483
|
+
out2.push(` ${rule(64)}`);
|
|
484
|
+
out2.push("");
|
|
485
|
+
const spinner = options.live ? `${style.accent(FRAMES[tick % FRAMES.length] ?? "")} ` : "";
|
|
486
|
+
out2.push(
|
|
487
|
+
` ${spinner}${style.quiet("watching")} ${String(runs.length)} runs, ${String(active)} live ${style.quiet("\xB7")} ${String(recent.length)} recent actions ${style.quiet("\xB7")} ${waiting.length > 0 ? style.accent(`${String(waiting.length)} awaiting approval`) : style.quiet("nothing waiting")}`
|
|
488
|
+
);
|
|
489
|
+
out2.push("");
|
|
490
|
+
if (recent.length === 0) {
|
|
491
|
+
out2.push(` ${style.quiet("No agent has done anything through this journal yet.")}`);
|
|
492
|
+
} else {
|
|
493
|
+
for (const action of recent) {
|
|
494
|
+
out2.push(line(action));
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (waiting.length > 0) {
|
|
498
|
+
const at = Math.min(view.cursor, waiting.length - 1);
|
|
499
|
+
out2.push("");
|
|
500
|
+
out2.push(` ${style.label("awaiting approval")}`);
|
|
501
|
+
waiting.forEach((action, index) => {
|
|
502
|
+
const here = index === at && canDecide(options);
|
|
503
|
+
const mark = here ? style.accent("\u276F") : " ";
|
|
504
|
+
const name = here ? style.accent(`${action.server}.${action.tool}`) : style.quiet(`${action.server}.${action.tool}`);
|
|
505
|
+
out2.push(` ${mark} ${name} ${style.quiet(truncate(JSON.stringify(action.args), 56))}`);
|
|
506
|
+
});
|
|
507
|
+
out2.push("");
|
|
508
|
+
out2.push(
|
|
509
|
+
canDecide(options) ? ` ${keyHint("a", "approve")} ${keyHint("d", "deny")} ${keyHint("j/k", "move")} ${keyHint("q", "quit")}` : ` ${style.quiet(`${options.approveWith} approve --all`)}`
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
if (view.notice !== "") {
|
|
513
|
+
out2.push("");
|
|
514
|
+
out2.push(` ${style.accent(view.notice)}`);
|
|
515
|
+
}
|
|
516
|
+
out2.push("");
|
|
517
|
+
return out2.join("\n");
|
|
518
|
+
}
|
|
519
|
+
function truncate(text, limit) {
|
|
520
|
+
return text.length <= limit ? text : `${text.slice(0, limit - 3)}...`;
|
|
521
|
+
}
|
|
522
|
+
function keyHint(key, what) {
|
|
523
|
+
return `${style.strong(`[${key}]`)} ${style.quiet(what)}`;
|
|
524
|
+
}
|
|
525
|
+
function isTerminal(value) {
|
|
526
|
+
return value === true;
|
|
527
|
+
}
|
|
528
|
+
function canDecide(options) {
|
|
529
|
+
const keyboard = options.keys !== void 0 || isTerminal(process.stdin.isTTY);
|
|
530
|
+
return options.live && options.decideAs !== void 0 && keyboard;
|
|
531
|
+
}
|
|
532
|
+
async function* terminalKeys() {
|
|
533
|
+
const input = process.stdin;
|
|
534
|
+
if (!input.isTTY) {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
input.setRawMode(true);
|
|
538
|
+
input.resume();
|
|
539
|
+
try {
|
|
540
|
+
for await (const chunk of input) {
|
|
541
|
+
const raw = chunk;
|
|
542
|
+
const text = typeof raw === "string" ? raw : Buffer.isBuffer(raw) ? raw.toString("utf8") : "";
|
|
543
|
+
for (const key of keysIn(text)) {
|
|
544
|
+
yield key;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
} finally {
|
|
548
|
+
input.setRawMode(false);
|
|
549
|
+
input.pause();
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
async function watch(options) {
|
|
553
|
+
let journal;
|
|
554
|
+
const open = () => {
|
|
555
|
+
if (journal === void 0 && existsSync(options.journalPath)) {
|
|
556
|
+
journal = openJournal(options.journalPath, { mustExist: true });
|
|
557
|
+
}
|
|
558
|
+
return journal;
|
|
559
|
+
};
|
|
560
|
+
const interval = options.intervalMs ?? 120;
|
|
561
|
+
const clear = "\x1B[H\x1B[2J\x1B[3J";
|
|
562
|
+
const view = { stop: false, cursor: 0, notice: "", noticeUntil: 0 };
|
|
563
|
+
let tick = 0;
|
|
564
|
+
const frame = (tick2) => {
|
|
565
|
+
const ready = open();
|
|
566
|
+
return ready === void 0 ? waitingForJournal(options, tick2) : render(ready, options, tick2, view);
|
|
567
|
+
};
|
|
568
|
+
const decide = (approve) => {
|
|
569
|
+
const ready = open();
|
|
570
|
+
if (ready === void 0 || options.decideAs === void 0) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const waiting = ready.listGated();
|
|
574
|
+
const action = waiting[Math.min(view.cursor, waiting.length - 1)];
|
|
575
|
+
if (action === void 0) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const changed = approve ? ready.approve(action.id, options.decideAs) : ready.deny(action.id, options.decideAs, "denied from the watch view");
|
|
579
|
+
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`;
|
|
580
|
+
view.noticeUntil = tick + NOTICE_TICKS;
|
|
581
|
+
view.cursor = 0;
|
|
582
|
+
};
|
|
583
|
+
const press = (key) => {
|
|
584
|
+
switch (key) {
|
|
585
|
+
case "q":
|
|
586
|
+
case "":
|
|
587
|
+
view.stop = true;
|
|
588
|
+
return;
|
|
589
|
+
case "a":
|
|
590
|
+
decide(true);
|
|
591
|
+
return;
|
|
592
|
+
case "d":
|
|
593
|
+
decide(false);
|
|
594
|
+
return;
|
|
595
|
+
case "j":
|
|
596
|
+
case "\x1B[B":
|
|
597
|
+
view.cursor += 1;
|
|
598
|
+
return;
|
|
599
|
+
case "k":
|
|
600
|
+
case "\x1B[A":
|
|
601
|
+
view.cursor = Math.max(0, view.cursor - 1);
|
|
602
|
+
return;
|
|
603
|
+
default:
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
const stopped = () => view.stop;
|
|
608
|
+
const onSignal = () => {
|
|
609
|
+
view.stop = true;
|
|
610
|
+
};
|
|
611
|
+
process.on("SIGINT", onSignal);
|
|
612
|
+
process.on("SIGTERM", onSignal);
|
|
613
|
+
const source = canDecide(options) ? options.keys ?? terminalKeys() : void 0;
|
|
614
|
+
const reader = source?.[Symbol.asyncIterator]();
|
|
615
|
+
const reading = reader === void 0 ? Promise.resolve() : (async () => {
|
|
616
|
+
for (; ; ) {
|
|
617
|
+
const next = await reader.next();
|
|
618
|
+
if (next.done === true || stopped()) {
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
press(next.value);
|
|
622
|
+
if (stopped()) {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
})();
|
|
627
|
+
try {
|
|
628
|
+
if (!options.live) {
|
|
629
|
+
options.write(`${frame(0)}
|
|
630
|
+
`);
|
|
631
|
+
return 0;
|
|
632
|
+
}
|
|
633
|
+
options.write("\x1B[?25l");
|
|
634
|
+
for (; !view.stop; tick += 1) {
|
|
635
|
+
if (view.notice !== "" && tick >= view.noticeUntil) {
|
|
636
|
+
view.notice = "";
|
|
637
|
+
}
|
|
638
|
+
options.write(clear + frame(tick));
|
|
639
|
+
if (options.maxTicks !== void 0 && tick + 1 >= options.maxTicks) {
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
await new Promise((resolve2) => setTimeout(resolve2, interval));
|
|
643
|
+
}
|
|
644
|
+
return 0;
|
|
645
|
+
} finally {
|
|
646
|
+
if (options.live) {
|
|
647
|
+
options.write("\x1B[?25h\n");
|
|
648
|
+
}
|
|
649
|
+
process.off("SIGINT", onSignal);
|
|
650
|
+
process.off("SIGTERM", onSignal);
|
|
651
|
+
view.stop = true;
|
|
652
|
+
await Promise.race([
|
|
653
|
+
(async () => {
|
|
654
|
+
await reader?.return?.(void 0);
|
|
655
|
+
await reading;
|
|
656
|
+
})(),
|
|
657
|
+
new Promise((resolve2) => setTimeout(resolve2, 50).unref())
|
|
658
|
+
]);
|
|
659
|
+
journal?.close();
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// src/console.ts
|
|
664
|
+
import { existsSync as existsSync2 } from "fs";
|
|
665
|
+
var FRAMES2 = [
|
|
666
|
+
"\u280B",
|
|
667
|
+
"\u2819",
|
|
668
|
+
"\u2839",
|
|
669
|
+
"\u2838",
|
|
670
|
+
"\u283C",
|
|
671
|
+
"\u2834",
|
|
672
|
+
"\u2826",
|
|
673
|
+
"\u2827",
|
|
674
|
+
"\u2807",
|
|
675
|
+
"\u280F"
|
|
676
|
+
];
|
|
677
|
+
var MARK2 = {
|
|
678
|
+
readonly: "\xB7",
|
|
679
|
+
reversible: "\u2190",
|
|
680
|
+
compensable: "\u2248",
|
|
681
|
+
irreversible: "!",
|
|
682
|
+
unclassified: "?"
|
|
683
|
+
};
|
|
684
|
+
var CURSOR = "\u276F";
|
|
685
|
+
var DOT = "\xB7";
|
|
686
|
+
var ESC = "\x1B";
|
|
687
|
+
var NOTICE_TICKS2 = 26;
|
|
688
|
+
function roomFor(options) {
|
|
689
|
+
const rows = options.rows ?? rowsOf(process.stdout.rows) ?? 24;
|
|
690
|
+
return Math.max(3, rows - 11);
|
|
691
|
+
}
|
|
692
|
+
function windowed(lines, at, room) {
|
|
693
|
+
if (lines.length <= room) {
|
|
694
|
+
return [...lines];
|
|
695
|
+
}
|
|
696
|
+
const start = Math.max(0, Math.min(at - Math.floor(room / 2), lines.length - room));
|
|
697
|
+
const shown = lines.slice(start, start + room);
|
|
698
|
+
const above = start;
|
|
699
|
+
const below = lines.length - (start + room);
|
|
700
|
+
return [
|
|
701
|
+
...above === 0 ? [] : [` ${style.quiet(`${String(above)} more above`)}`],
|
|
702
|
+
...shown.slice(above === 0 ? 0 : 1, below === 0 ? void 0 : -1),
|
|
703
|
+
...below === 0 ? [] : [` ${style.quiet(`${String(below)} more below`)}`]
|
|
704
|
+
];
|
|
705
|
+
}
|
|
706
|
+
function truncate2(text, limit) {
|
|
707
|
+
return text.length <= limit ? text : `${text.slice(0, limit - 3)}...`;
|
|
708
|
+
}
|
|
709
|
+
function keyHint2(key, what) {
|
|
710
|
+
return `${style.strong(`[${key}]`)} ${style.quiet(what)}`;
|
|
711
|
+
}
|
|
712
|
+
function isTerminal2(value) {
|
|
713
|
+
return value === true;
|
|
714
|
+
}
|
|
715
|
+
function rowsOf(value) {
|
|
716
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
|
|
717
|
+
}
|
|
718
|
+
function canPress(options) {
|
|
719
|
+
return options.live && (options.keys !== void 0 || isTerminal2(process.stdin.isTTY));
|
|
720
|
+
}
|
|
721
|
+
function modeLabel(screen) {
|
|
722
|
+
switch (screen.mode) {
|
|
723
|
+
case "runs":
|
|
724
|
+
return "everything an agent has done through this journal";
|
|
725
|
+
case "run":
|
|
726
|
+
return "one run, in the order it happened";
|
|
727
|
+
case "gates":
|
|
728
|
+
return "held until a person decides";
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
function header(options, screen, tick) {
|
|
732
|
+
const spinner = options.live ? `${style.accent(FRAMES2[tick % FRAMES2.length] ?? "")} ` : "";
|
|
733
|
+
return [
|
|
734
|
+
"",
|
|
735
|
+
` ${style.plate(WORDMARK)} ${style.quiet(options.journalPath)}`,
|
|
736
|
+
` ${rule(70)}`,
|
|
737
|
+
"",
|
|
738
|
+
` ${spinner}${style.quiet(screen.busy ?? modeLabel(screen))}`,
|
|
739
|
+
""
|
|
740
|
+
];
|
|
741
|
+
}
|
|
742
|
+
function runsView(journal, screen, options) {
|
|
743
|
+
const runs = [...journal.listRuns()].reverse();
|
|
744
|
+
if (runs.length === 0) {
|
|
745
|
+
return [
|
|
746
|
+
` ${style.quiet("No agent has done anything through this journal yet.")}`,
|
|
747
|
+
"",
|
|
748
|
+
` ${style.quiet("A run appears the first time one calls a tool through the proxy.")}`
|
|
749
|
+
];
|
|
750
|
+
}
|
|
751
|
+
const at = Math.min(screen.cursor, runs.length - 1);
|
|
752
|
+
return runs.map((run, index) => {
|
|
753
|
+
const actions = journal.getActions(run.id);
|
|
754
|
+
const held = actions.filter((action) => action.status === "gated").length;
|
|
755
|
+
const here = index === at && canPress(options);
|
|
756
|
+
const name = (run.label ?? "an agent").padEnd(24);
|
|
757
|
+
const note = held === 0 ? "" : ` ${style.accent(`${String(held)} awaiting approval`)}`;
|
|
758
|
+
return ` ${here ? style.accent(CURSOR) : " "} ${here ? style.accent(name) : style.strong(name)} ${style.quiet(run.startedAt.slice(0, 19).replace("T", " "))} ${style.quiet(run.status.padEnd(11))} ${style.quiet(`${String(actions.length)} actions`)}${note}`;
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
function statusOf(action) {
|
|
762
|
+
const text = labelFor(action).padEnd(13);
|
|
763
|
+
if (wasRefused(action)) {
|
|
764
|
+
return style.accent(text);
|
|
765
|
+
}
|
|
766
|
+
return action.status === "gated" ? style.strong(text) : style.quiet(text);
|
|
767
|
+
}
|
|
768
|
+
function runView(journal, screen) {
|
|
769
|
+
const runId = screen.openRun;
|
|
770
|
+
if (runId === void 0) {
|
|
771
|
+
return [` ${style.quiet("no run selected")}`];
|
|
772
|
+
}
|
|
773
|
+
const run = journal.getRun(runId);
|
|
774
|
+
const actions = journal.getActions(runId);
|
|
775
|
+
const out2 = [
|
|
776
|
+
` ${style.label("run")} ${style.strong(run?.label ?? "an agent")} ${style.quiet(runId.slice(0, 8))}`,
|
|
777
|
+
""
|
|
778
|
+
];
|
|
779
|
+
if (actions.length === 0) {
|
|
780
|
+
out2.push(` ${style.quiet("nothing was recorded in this run")}`);
|
|
781
|
+
return out2;
|
|
782
|
+
}
|
|
783
|
+
for (const action of actions) {
|
|
784
|
+
const badge = `${MARK2[action.class] ?? "?"} ${action.class}`.padEnd(14);
|
|
785
|
+
out2.push(
|
|
786
|
+
` ${style.quiet(String(action.seq).padStart(3))} ${style.quiet(badge)} ${statusOf(action)} ${style.strong(`${action.server}.${action.tool}`)}`
|
|
787
|
+
);
|
|
788
|
+
out2.push(` ${style.quiet(truncate2(JSON.stringify(action.args), 62))}`);
|
|
789
|
+
if (action.inverse !== void 0) {
|
|
790
|
+
out2.push(` ${style.quiet(`undo: ${truncate2(JSON.stringify(action.inverse), 56)}`)}`);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return out2;
|
|
794
|
+
}
|
|
795
|
+
function gatesView(journal, screen, options) {
|
|
796
|
+
const waiting = journal.listGated();
|
|
797
|
+
if (waiting.length === 0) {
|
|
798
|
+
return [` ${style.quiet("Nothing is waiting for a decision.")}`];
|
|
799
|
+
}
|
|
800
|
+
const at = Math.min(screen.cursor, waiting.length - 1);
|
|
801
|
+
return waiting.map((action, index) => {
|
|
802
|
+
const here = index === at && canPress(options);
|
|
803
|
+
const name = `${action.server}.${action.tool}`;
|
|
804
|
+
const shown = here ? style.accent(name) : style.quiet(name);
|
|
805
|
+
const args = style.quiet(truncate2(JSON.stringify(action.args), 54));
|
|
806
|
+
return ` ${here ? style.accent(CURSOR) : " "} ${shown} ${args}`;
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
function footer(screen, options) {
|
|
810
|
+
if (!canPress(options)) {
|
|
811
|
+
return [];
|
|
812
|
+
}
|
|
813
|
+
if (screen.confirming !== void 0) {
|
|
814
|
+
return [
|
|
815
|
+
"",
|
|
816
|
+
` ${style.accent("undo this whole run?")} ${keyHint2("y", "yes")} ${keyHint2("n", "no")}`
|
|
817
|
+
];
|
|
818
|
+
}
|
|
819
|
+
const keys = screen.mode === "gates" ? [keyHint2("a", "approve"), keyHint2("d", "deny"), keyHint2("j/k", "move"), keyHint2("r", "runs")] : screen.mode === "run" ? [
|
|
820
|
+
keyHint2("p", "preview undo"),
|
|
821
|
+
keyHint2("u", "undo"),
|
|
822
|
+
keyHint2("esc", "back"),
|
|
823
|
+
keyHint2("g", "held")
|
|
824
|
+
] : [
|
|
825
|
+
keyHint2("enter", "open"),
|
|
826
|
+
keyHint2("p", "preview undo"),
|
|
827
|
+
keyHint2("u", "undo"),
|
|
828
|
+
keyHint2("j/k", "move"),
|
|
829
|
+
keyHint2("g", "held")
|
|
830
|
+
];
|
|
831
|
+
return ["", ` ${keys.join(" ")} ${keyHint2("q", "quit")}`];
|
|
832
|
+
}
|
|
833
|
+
function waitingForJournal2(options, tick) {
|
|
834
|
+
const spinner = options.live ? `${style.accent(FRAMES2[tick % FRAMES2.length] ?? "")} ` : "";
|
|
835
|
+
return [
|
|
836
|
+
"",
|
|
837
|
+
` ${style.plate(WORDMARK)} ${style.quiet(options.journalPath)}`,
|
|
838
|
+
` ${rule(70)}`,
|
|
839
|
+
"",
|
|
840
|
+
` ${spinner}${style.quiet("no journal here yet")}`,
|
|
841
|
+
"",
|
|
842
|
+
` ${style.quiet("One appears the first time an agent calls a tool through the proxy.")}`,
|
|
843
|
+
` ${style.quiet("Point your client at it, then work as usual; this will fill in.")}`,
|
|
844
|
+
""
|
|
845
|
+
].join("\n");
|
|
846
|
+
}
|
|
847
|
+
async function* terminalKeys2() {
|
|
848
|
+
const input = process.stdin;
|
|
849
|
+
if (!isTerminal2(input.isTTY)) {
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
input.setRawMode(true);
|
|
853
|
+
input.resume();
|
|
854
|
+
try {
|
|
855
|
+
for await (const chunk of input) {
|
|
856
|
+
const raw = chunk;
|
|
857
|
+
const text = typeof raw === "string" ? raw : Buffer.isBuffer(raw) ? raw.toString("utf8") : "";
|
|
858
|
+
for (const key of keysIn(text)) {
|
|
859
|
+
yield key;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
} finally {
|
|
863
|
+
input.setRawMode(false);
|
|
864
|
+
input.pause();
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
async function openConsole(options) {
|
|
868
|
+
let journal;
|
|
869
|
+
const open = () => {
|
|
870
|
+
if (journal === void 0 && existsSync2(options.journalPath)) {
|
|
871
|
+
journal = openJournal(options.journalPath, { mustExist: true });
|
|
872
|
+
}
|
|
873
|
+
return journal;
|
|
874
|
+
};
|
|
875
|
+
const screen = {
|
|
876
|
+
stop: false,
|
|
877
|
+
mode: "runs",
|
|
878
|
+
cursor: 0,
|
|
879
|
+
openRun: void 0,
|
|
880
|
+
confirming: void 0,
|
|
881
|
+
busy: void 0,
|
|
882
|
+
notice: "",
|
|
883
|
+
noticeUntil: 0
|
|
884
|
+
};
|
|
885
|
+
let tick = 0;
|
|
886
|
+
const stopped = () => screen.stop;
|
|
887
|
+
const say = (text) => {
|
|
888
|
+
screen.notice = text;
|
|
889
|
+
screen.noticeUntil = tick + NOTICE_TICKS2;
|
|
890
|
+
};
|
|
891
|
+
const frame = () => {
|
|
892
|
+
const ready = open();
|
|
893
|
+
if (ready === void 0) {
|
|
894
|
+
return waitingForJournal2(options, tick);
|
|
895
|
+
}
|
|
896
|
+
const room = roomFor(options);
|
|
897
|
+
const body = screen.mode === "runs" ? windowed(runsView(ready, screen, options), screen.cursor, room) : screen.mode === "run" ? windowed(runView(ready, screen), 0, room) : windowed(gatesView(ready, screen, options), screen.cursor, room);
|
|
898
|
+
const notice = screen.notice === "" ? [] : ["", ` ${style.accent(screen.notice)}`];
|
|
899
|
+
return [
|
|
900
|
+
...header(options, screen, tick),
|
|
901
|
+
...body,
|
|
902
|
+
...notice,
|
|
903
|
+
...footer(screen, options),
|
|
904
|
+
""
|
|
905
|
+
].join("\n");
|
|
906
|
+
};
|
|
907
|
+
const selectedRun = (ready) => {
|
|
908
|
+
if (screen.mode === "run" && screen.openRun !== void 0) {
|
|
909
|
+
return ready.getRun(screen.openRun);
|
|
910
|
+
}
|
|
911
|
+
const runs = [...ready.listRuns()].reverse();
|
|
912
|
+
return runs[Math.min(screen.cursor, runs.length - 1)];
|
|
913
|
+
};
|
|
914
|
+
const decide = (approve) => {
|
|
915
|
+
const ready = open();
|
|
916
|
+
if (ready === void 0) {
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
const waiting = ready.listGated();
|
|
920
|
+
const action = waiting[Math.min(screen.cursor, waiting.length - 1)];
|
|
921
|
+
if (action === void 0) {
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const changed = approve ? ready.approve(action.id, options.decideAs) : ready.deny(action.id, options.decideAs, "denied from the console");
|
|
925
|
+
say(
|
|
926
|
+
!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`
|
|
927
|
+
);
|
|
928
|
+
screen.cursor = 0;
|
|
929
|
+
};
|
|
930
|
+
const perform = async (runId, dryRun) => {
|
|
931
|
+
if (options.undo === void 0) {
|
|
932
|
+
say("no way to undo was configured");
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
if (screen.busy !== void 0) {
|
|
936
|
+
say("still working on the last one");
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
screen.busy = dryRun ? "reading the current state..." : "putting it back...";
|
|
940
|
+
try {
|
|
941
|
+
const report2 = await options.undo(runId, dryRun);
|
|
942
|
+
const reverted = report2.steps.filter((step) => step.kind === "revert").length;
|
|
943
|
+
const halted = report2.halted === void 0 ? "" : ` ${DOT} halted: ${report2.halted.reason}`;
|
|
944
|
+
say(
|
|
945
|
+
dryRun ? `${String(reverted)} would be reverted ${DOT} nothing changed${halted}` : `${report2.status} ${DOT} ${String(reverted)} reverted${halted}`
|
|
946
|
+
);
|
|
947
|
+
} catch (error) {
|
|
948
|
+
say(error instanceof Error ? error.message : "the undo failed");
|
|
949
|
+
} finally {
|
|
950
|
+
screen.busy = void 0;
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
const press = (key) => {
|
|
954
|
+
if (screen.confirming !== void 0) {
|
|
955
|
+
const runId = screen.confirming;
|
|
956
|
+
screen.confirming = void 0;
|
|
957
|
+
if (key === "y") {
|
|
958
|
+
void perform(runId, false);
|
|
959
|
+
} else {
|
|
960
|
+
say("left alone");
|
|
961
|
+
}
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
switch (key) {
|
|
965
|
+
case "q":
|
|
966
|
+
case "":
|
|
967
|
+
screen.stop = true;
|
|
968
|
+
return;
|
|
969
|
+
case "j":
|
|
970
|
+
case `${ESC}[B`:
|
|
971
|
+
screen.cursor += 1;
|
|
972
|
+
return;
|
|
973
|
+
case "k":
|
|
974
|
+
case `${ESC}[A`:
|
|
975
|
+
screen.cursor = Math.max(0, screen.cursor - 1);
|
|
976
|
+
return;
|
|
977
|
+
case "g":
|
|
978
|
+
screen.mode = "gates";
|
|
979
|
+
screen.cursor = 0;
|
|
980
|
+
return;
|
|
981
|
+
case "r":
|
|
982
|
+
screen.mode = "runs";
|
|
983
|
+
screen.cursor = 0;
|
|
984
|
+
return;
|
|
985
|
+
case ESC:
|
|
986
|
+
case "h":
|
|
987
|
+
screen.mode = "runs";
|
|
988
|
+
screen.openRun = void 0;
|
|
989
|
+
return;
|
|
990
|
+
case "\r":
|
|
991
|
+
case "\n": {
|
|
992
|
+
const ready = open();
|
|
993
|
+
const run = ready === void 0 ? void 0 : selectedRun(ready);
|
|
994
|
+
if (run !== void 0) {
|
|
995
|
+
screen.openRun = run.id;
|
|
996
|
+
screen.mode = "run";
|
|
997
|
+
}
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
case "a":
|
|
1001
|
+
if (screen.mode === "gates") {
|
|
1002
|
+
decide(true);
|
|
1003
|
+
}
|
|
1004
|
+
return;
|
|
1005
|
+
case "d":
|
|
1006
|
+
if (screen.mode === "gates") {
|
|
1007
|
+
decide(false);
|
|
1008
|
+
}
|
|
1009
|
+
return;
|
|
1010
|
+
case "p": {
|
|
1011
|
+
const ready = open();
|
|
1012
|
+
const run = ready === void 0 ? void 0 : selectedRun(ready);
|
|
1013
|
+
if (run !== void 0) {
|
|
1014
|
+
void perform(run.id, true);
|
|
1015
|
+
}
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
case "u": {
|
|
1019
|
+
if (screen.busy !== void 0) {
|
|
1020
|
+
say("still working on the last one");
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
const ready = open();
|
|
1024
|
+
const run = ready === void 0 ? void 0 : selectedRun(ready);
|
|
1025
|
+
if (run !== void 0) {
|
|
1026
|
+
screen.confirming = run.id;
|
|
1027
|
+
}
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
default:
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
const onSignal = () => {
|
|
1035
|
+
screen.stop = true;
|
|
1036
|
+
};
|
|
1037
|
+
process.on("SIGINT", onSignal);
|
|
1038
|
+
process.on("SIGTERM", onSignal);
|
|
1039
|
+
const source = canPress(options) ? options.keys ?? terminalKeys2() : void 0;
|
|
1040
|
+
const reader = source?.[Symbol.asyncIterator]();
|
|
1041
|
+
const reading = reader === void 0 ? Promise.resolve() : (async () => {
|
|
1042
|
+
for (; ; ) {
|
|
1043
|
+
const next = await reader.next();
|
|
1044
|
+
if (next.done === true || stopped()) {
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
press(next.value);
|
|
1048
|
+
if (stopped()) {
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
})();
|
|
1053
|
+
const interval = options.intervalMs ?? 120;
|
|
1054
|
+
const clear = `${ESC}[H${ESC}[2J${ESC}[3J`;
|
|
1055
|
+
try {
|
|
1056
|
+
if (!options.live) {
|
|
1057
|
+
options.write(`${frame()}
|
|
1058
|
+
`);
|
|
1059
|
+
return 0;
|
|
1060
|
+
}
|
|
1061
|
+
options.write(`${ESC}[?25l`);
|
|
1062
|
+
for (; !screen.stop; tick += 1) {
|
|
1063
|
+
if (screen.notice !== "" && tick >= screen.noticeUntil) {
|
|
1064
|
+
screen.notice = "";
|
|
1065
|
+
}
|
|
1066
|
+
options.write(clear + frame());
|
|
1067
|
+
if (options.maxTicks !== void 0 && tick + 1 >= options.maxTicks) {
|
|
1068
|
+
break;
|
|
1069
|
+
}
|
|
1070
|
+
await new Promise((resolve2) => setTimeout(resolve2, interval));
|
|
1071
|
+
}
|
|
1072
|
+
return 0;
|
|
1073
|
+
} finally {
|
|
1074
|
+
if (options.live) {
|
|
1075
|
+
options.write(`${ESC}[?25h
|
|
1076
|
+
`);
|
|
1077
|
+
}
|
|
1078
|
+
process.off("SIGINT", onSignal);
|
|
1079
|
+
process.off("SIGTERM", onSignal);
|
|
1080
|
+
screen.stop = true;
|
|
1081
|
+
await Promise.race([
|
|
1082
|
+
(async () => {
|
|
1083
|
+
await reader?.return?.(void 0);
|
|
1084
|
+
await reading;
|
|
1085
|
+
})(),
|
|
1086
|
+
new Promise((resolve2) => setTimeout(resolve2, 50).unref())
|
|
1087
|
+
]);
|
|
1088
|
+
journal?.close();
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// src/cli.ts
|
|
1093
|
+
var NODE_MAJOR = Number(process.versions.node.split(".")[0]);
|
|
1094
|
+
if (NODE_MAJOR < 22) {
|
|
1095
|
+
process.stderr.write(
|
|
1096
|
+
`synartesis: needs Node 22 or newer, and this is ${process.version}.
|
|
1097
|
+
`
|
|
1098
|
+
);
|
|
1099
|
+
process.exit(2);
|
|
1100
|
+
}
|
|
1101
|
+
var COMMANDS = `
|
|
1102
|
+
synartesis the screen; everything below,
|
|
1103
|
+
driven with the arrow keys
|
|
1104
|
+
synartesis init <server> -- <command> [args...] [--manifest <path>]
|
|
1105
|
+
synartesis check [--manifest <path>]
|
|
1106
|
+
synartesis list [--journal <path>]
|
|
1107
|
+
synartesis show <runId> [--journal <path>]
|
|
1108
|
+
synartesis gates [--journal <path>]
|
|
1109
|
+
synartesis close [runId] [--journal <path>]
|
|
1110
|
+
synartesis proxy --manifest <path> [--journal <path>] what your agent runs
|
|
1111
|
+
synartesis watch [--by <name>] [--journal <path>]
|
|
1112
|
+
synartesis approve [actionId|--all] [--by <name>] [--journal <path>]
|
|
1113
|
+
synartesis deny [actionId|--all] [--by <name>] [--reason <text>] [--journal <path>]
|
|
1114
|
+
synartesis undo [runId] [--to <seq>] [--dry-run] [--replan]
|
|
1115
|
+
[--manifest <path>] [--journal <path>]
|
|
1116
|
+
|
|
1117
|
+
close ends a run left active by a proxy that was killed; nothing guesses at
|
|
1118
|
+
that, since several proxies can share one journal.
|
|
1119
|
+
|
|
1120
|
+
Ids may be shortened to any unambiguous prefix. show and undo default to the
|
|
1121
|
+
most recent run; approve and deny default to the only request waiting. init
|
|
1122
|
+
adds to an existing manifest rather than replacing it.
|
|
1123
|
+
|
|
1124
|
+
watch is the one to leave running. Anything held for approval appears there,
|
|
1125
|
+
and a and d answer it without a second terminal or an id to copy.
|
|
1126
|
+
|
|
1127
|
+
--manifest synartesis.yaml, looked for here and upwards, then in the home
|
|
1128
|
+
--journal beside the manifest, or the one in the home
|
|
1129
|
+
--to lowest sequence to undo; earlier actions are left alone
|
|
1130
|
+
--by who is deciding; defaults to the logged-in user
|
|
1131
|
+
--all approve or deny everything currently waiting
|
|
1132
|
+
--once watch prints the current state and exits
|
|
1133
|
+
--json machine-readable output for list, show and gates
|
|
1134
|
+
--dry-run read current state and print the plan without changing anything
|
|
1135
|
+
--replan rebuild each undo from the current manifest, for a run recorded
|
|
1136
|
+
under a policy that turned out to be wrong
|
|
1137
|
+
|
|
1138
|
+
Neither path usually needs giving. A policy that belongs to a project sits in
|
|
1139
|
+
it and is found from any directory inside it, the way a version control tool
|
|
1140
|
+
finds its root; anything else lives in ~/.synartesis, which is where the
|
|
1141
|
+
journal is too. Set SYNARTESIS_HOME to put that somewhere else.
|
|
1142
|
+
|
|
1143
|
+
Exit codes: 0 complete, 1 halted or partial, 2 bad usage or configuration.
|
|
1144
|
+
`;
|
|
1145
|
+
var UsageError = class extends Error {
|
|
1146
|
+
};
|
|
1147
|
+
function flag(argv, name) {
|
|
1148
|
+
const at = argv.indexOf(name);
|
|
1149
|
+
if (at === -1) {
|
|
1150
|
+
return void 0;
|
|
1151
|
+
}
|
|
1152
|
+
const value = argv[at + 1];
|
|
1153
|
+
if (value === void 0 || value.startsWith("--")) {
|
|
1154
|
+
throw new UsageError(`${name} needs a value`);
|
|
1155
|
+
}
|
|
1156
|
+
return value;
|
|
1157
|
+
}
|
|
1158
|
+
function positional(argv) {
|
|
1159
|
+
const skip = /* @__PURE__ */ new Set(["--manifest", "--journal", "--to", "--by", "--reason", "--gate-timeout"]);
|
|
1160
|
+
const values = [];
|
|
1161
|
+
const end = argv.indexOf("--");
|
|
1162
|
+
const ours = end === -1 ? argv : argv.slice(0, end);
|
|
1163
|
+
for (let i = 0; i < ours.length; i += 1) {
|
|
1164
|
+
const token = ours[i] ?? "";
|
|
1165
|
+
if (skip.has(token)) {
|
|
1166
|
+
i += 1;
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
if (!token.startsWith("--")) {
|
|
1170
|
+
values.push(token);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
return values;
|
|
1174
|
+
}
|
|
1175
|
+
async function runCheck(argv) {
|
|
1176
|
+
const path = findManifest(flag(argv, "--manifest"));
|
|
1177
|
+
const manifest = loadManifest(path);
|
|
1178
|
+
const upstreams = [];
|
|
1179
|
+
try {
|
|
1180
|
+
for (const [name, spec] of Object.entries(manifest.servers)) {
|
|
1181
|
+
upstreams.push(
|
|
1182
|
+
await connectStdioUpstream({
|
|
1183
|
+
name,
|
|
1184
|
+
command: spec.command,
|
|
1185
|
+
args: spec.args,
|
|
1186
|
+
stderr: "capture",
|
|
1187
|
+
...spec.env === void 0 ? {} : { env: spec.env }
|
|
1188
|
+
})
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
await verifyAgainstServers(upstreams, manifest);
|
|
1192
|
+
} finally {
|
|
1193
|
+
for (const upstream of upstreams) {
|
|
1194
|
+
await upstream.close();
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1198
|
+
for (const policy of manifest.tools) {
|
|
1199
|
+
counts.set(policy.class, (counts.get(policy.class) ?? 0) + 1);
|
|
1200
|
+
}
|
|
1201
|
+
const gated = manifest.tools.filter((policy) => policy.gate !== "never").length;
|
|
1202
|
+
out("");
|
|
1203
|
+
out(` ${style.label("policy")} ${style.strong(path)}`);
|
|
1204
|
+
out(` ${rule(54)}`);
|
|
1205
|
+
out("");
|
|
1206
|
+
out(` ${style.quiet("servers ")} ${Object.keys(manifest.servers).join(", ")}`);
|
|
1207
|
+
out(` ${style.quiet("policies")} ${[...counts].map(([k, v]) => `${String(v)} ${k}`).join(", ")}`);
|
|
1208
|
+
out(` ${style.quiet("guarded ")} ${style.accent(String(gated))}`);
|
|
1209
|
+
out("");
|
|
1210
|
+
out(` ${style.quiet("Anything not mentioned here is treated as irreversible and guarded.")}`);
|
|
1211
|
+
out("");
|
|
1212
|
+
return 0;
|
|
1213
|
+
}
|
|
1214
|
+
async function runInit(argv) {
|
|
1215
|
+
const name = positional(argv)[1];
|
|
1216
|
+
const separator = argv.indexOf("--");
|
|
1217
|
+
const command = separator === -1 ? void 0 : argv[separator + 1];
|
|
1218
|
+
if (name === void 0 || command === void 0) {
|
|
1219
|
+
throw new UsageError("init needs a server name and a command, as: init crm -- npx -y some-mcp-server");
|
|
1220
|
+
}
|
|
1221
|
+
if (name.includes(".") || name.includes("__")) {
|
|
1222
|
+
throw new UsageError(`server name ${name} may not contain "." or "__"; both qualify tool names`);
|
|
1223
|
+
}
|
|
1224
|
+
const path = findManifest(flag(argv, "--manifest"));
|
|
1225
|
+
const force = argv.includes("--force");
|
|
1226
|
+
const present = existsSync3(path);
|
|
1227
|
+
if (present && force) {
|
|
1228
|
+
throw new UsageError(
|
|
1229
|
+
`--force would discard ${path}. Delete it yourself if that is what you want; init will otherwise add to it.`
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1232
|
+
const yaml = await draftManifest({
|
|
1233
|
+
name,
|
|
1234
|
+
command,
|
|
1235
|
+
args: argv.slice(separator + 2),
|
|
1236
|
+
...present ? { existing: readFileSync(path, "utf8") } : {}
|
|
1237
|
+
});
|
|
1238
|
+
parseManifest(yaml, path);
|
|
1239
|
+
mkdirSync(dirname(resolve(path)), { recursive: true });
|
|
1240
|
+
writeFileSync(path, yaml);
|
|
1241
|
+
out("");
|
|
1242
|
+
out(` ${style.label(present ? "extended" : "wrote")} ${style.strong(path)}`);
|
|
1243
|
+
out(` ${rule(54)}`);
|
|
1244
|
+
out("");
|
|
1245
|
+
out(` ${style.quiet("Every tool is guarded until you say how to undo it.")}`);
|
|
1246
|
+
out(` ${style.quiet("Work through the TODOs, then point your MCP client at:")}`);
|
|
1247
|
+
out("");
|
|
1248
|
+
out(` ${style.accent(`${proxyCommand()} --manifest ${resolve(path)}`)}`);
|
|
1249
|
+
out("");
|
|
1250
|
+
return 0;
|
|
1251
|
+
}
|
|
1252
|
+
function pick(candidates, given, noun, newest = false) {
|
|
1253
|
+
const listed = (items) => items.map((item) => ` ${item.id}`).join("\n");
|
|
1254
|
+
if (given === void 0) {
|
|
1255
|
+
const [only, ...rest2] = candidates;
|
|
1256
|
+
if (only === void 0) {
|
|
1257
|
+
throw new UsageError(`there is no ${noun.one} to act on`);
|
|
1258
|
+
}
|
|
1259
|
+
if (rest2.length > 0 && !newest) {
|
|
1260
|
+
throw new UsageError(
|
|
1261
|
+
`there are ${String(candidates.length)} ${noun.many}; name one, or use --all:
|
|
1262
|
+
${listed(candidates)}`
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
return only;
|
|
1266
|
+
}
|
|
1267
|
+
const exact = candidates.find((item) => item.id === given);
|
|
1268
|
+
if (exact !== void 0) {
|
|
1269
|
+
return exact;
|
|
1270
|
+
}
|
|
1271
|
+
const matches = candidates.filter((item) => item.id.startsWith(given));
|
|
1272
|
+
const [first, ...rest] = matches;
|
|
1273
|
+
if (first === void 0) {
|
|
1274
|
+
throw new UsageError(`no ${noun.one} matches ${given}`);
|
|
1275
|
+
}
|
|
1276
|
+
if (rest.length > 0) {
|
|
1277
|
+
throw new UsageError(
|
|
1278
|
+
`${given} matches ${String(matches.length)} ${noun.many}:
|
|
1279
|
+
${listed(matches)}`
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
return first;
|
|
1283
|
+
}
|
|
1284
|
+
var RUN = { one: "run", many: "runs" };
|
|
1285
|
+
var WAITING = { one: "action awaiting approval", many: "actions awaiting approval" };
|
|
1286
|
+
process.stdout.on("error", (error) => {
|
|
1287
|
+
if (error.code === "EPIPE") {
|
|
1288
|
+
process.exit(0);
|
|
1289
|
+
}
|
|
1290
|
+
throw error;
|
|
1291
|
+
});
|
|
1292
|
+
function out(line2) {
|
|
1293
|
+
process.stdout.write(`${line2}
|
|
1294
|
+
`);
|
|
1295
|
+
}
|
|
1296
|
+
function runList(journal, asJson) {
|
|
1297
|
+
const runs = [...journal.listRuns()].reverse();
|
|
1298
|
+
if (asJson) {
|
|
1299
|
+
out(JSON.stringify(runs.map((run) => ({ ...run, actions: journal.getActions(run.id).length }))));
|
|
1300
|
+
return 0;
|
|
1301
|
+
}
|
|
1302
|
+
if (runs.length === 0) {
|
|
1303
|
+
out("no runs recorded");
|
|
1304
|
+
return 0;
|
|
1305
|
+
}
|
|
1306
|
+
out("");
|
|
1307
|
+
out(` ${style.label("runs")} ${style.quiet("most recent first")}`);
|
|
1308
|
+
out(` ${rule(96)}`);
|
|
1309
|
+
out("");
|
|
1310
|
+
out(
|
|
1311
|
+
style.quiet(
|
|
1312
|
+
` ${"run".padEnd(36)} ${"started".padEnd(24)} ${"status".padEnd(12)} actions agent`
|
|
1313
|
+
)
|
|
1314
|
+
);
|
|
1315
|
+
for (const run of runs) {
|
|
1316
|
+
const actions = journal.getActions(run.id);
|
|
1317
|
+
const unknown = actions.filter((action) => action.status === "pending").length;
|
|
1318
|
+
const waiting = actions.filter((action) => action.status === "gated").length;
|
|
1319
|
+
const notes = [
|
|
1320
|
+
unknown === 0 ? "" : `${String(unknown)} of unknown outcome`,
|
|
1321
|
+
waiting === 0 ? "" : `${String(waiting)} awaiting approval`
|
|
1322
|
+
].filter((note2) => note2 !== "");
|
|
1323
|
+
const note = notes.length === 0 ? "" : ` ${style.accent(`(${notes.join("; ")})`)}`;
|
|
1324
|
+
out(
|
|
1325
|
+
` ${style.strong(run.id)} ${style.quiet(run.startedAt)} ${run.status.padEnd(12)} ${String(actions.length).padStart(7)} ${run.label ?? "-"}${note}`
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
out("");
|
|
1329
|
+
return 0;
|
|
1330
|
+
}
|
|
1331
|
+
function runShow(argv, journal, asJson) {
|
|
1332
|
+
const runs = [...journal.listRuns()].reverse();
|
|
1333
|
+
const run = pick(runs, positional(argv)[1], RUN, true);
|
|
1334
|
+
const runId = run.id;
|
|
1335
|
+
if (asJson) {
|
|
1336
|
+
out(JSON.stringify({ run, actions: journal.getActions(runId) }));
|
|
1337
|
+
return 0;
|
|
1338
|
+
}
|
|
1339
|
+
out("");
|
|
1340
|
+
out(` ${style.label("run")} ${style.strong(run.id)}`);
|
|
1341
|
+
out(` ${rule(54)}`);
|
|
1342
|
+
out("");
|
|
1343
|
+
out(` ${style.quiet("agent ")} ${run.label ?? "-"}`);
|
|
1344
|
+
out(` ${style.quiet("started")} ${run.startedAt}`);
|
|
1345
|
+
out(
|
|
1346
|
+
` ${style.quiet("status ")} ${run.status}` + (run.endedAt === void 0 ? "" : style.quiet(` ended ${run.endedAt}`))
|
|
1347
|
+
);
|
|
1348
|
+
const actions = journal.getActions(runId);
|
|
1349
|
+
if (actions.length === 0) {
|
|
1350
|
+
out("");
|
|
1351
|
+
out("no actions recorded");
|
|
1352
|
+
return 0;
|
|
1353
|
+
}
|
|
1354
|
+
out("");
|
|
1355
|
+
out("");
|
|
1356
|
+
out(` ${style.label("timeline")}`);
|
|
1357
|
+
out(` ${rule(72)}`);
|
|
1358
|
+
out("");
|
|
1359
|
+
for (const action of actions) {
|
|
1360
|
+
out(
|
|
1361
|
+
` ${style.quiet(String(action.seq).padStart(3))} ${badgeOf(action)} ${statusOf2(action)} ${style.strong(`${action.server}.${action.tool}`)}`
|
|
1362
|
+
);
|
|
1363
|
+
out(` ${style.quiet(truncate3(JSON.stringify(action.args), 96))}`);
|
|
1364
|
+
if (action.approvedAt !== void 0) {
|
|
1365
|
+
const verb = action.status === "denied" ? "denied" : "approved";
|
|
1366
|
+
out(
|
|
1367
|
+
` ${style.accent(`${verb} by ${action.approvedBy ?? "nobody"}`)} ${style.quiet(`at ${action.approvedAt}`)}`
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
if (action.error !== void 0) {
|
|
1371
|
+
out(` ${style.quiet(`note: ${truncate3(action.error, 200)}`)}`);
|
|
1372
|
+
}
|
|
1373
|
+
if (action.inverse !== void 0) {
|
|
1374
|
+
out(` ${style.quiet("undo:")} ${truncate3(JSON.stringify(action.inverse), 200)}`);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
out("");
|
|
1378
|
+
out(` ${summarise2(actions)}`);
|
|
1379
|
+
out("");
|
|
1380
|
+
return 0;
|
|
1381
|
+
}
|
|
1382
|
+
var CLASS_MARK = {
|
|
1383
|
+
readonly: "\xB7",
|
|
1384
|
+
reversible: "\u2190",
|
|
1385
|
+
compensable: "\u2248",
|
|
1386
|
+
irreversible: "!",
|
|
1387
|
+
unclassified: "?"
|
|
1388
|
+
};
|
|
1389
|
+
var BADGE_WIDTH = "irreversible".length + 2;
|
|
1390
|
+
function badgeOf(action) {
|
|
1391
|
+
const plain = `${CLASS_MARK[action.class]} ${action.class}`.padEnd(BADGE_WIDTH);
|
|
1392
|
+
return action.class === "irreversible" ? style.accent(plain) : style.quiet(plain);
|
|
1393
|
+
}
|
|
1394
|
+
function statusOf2(action) {
|
|
1395
|
+
const text = labelFor(action).padEnd(13);
|
|
1396
|
+
if (wasRefused(action)) {
|
|
1397
|
+
return style.accent(text);
|
|
1398
|
+
}
|
|
1399
|
+
if (action.status === "gated") {
|
|
1400
|
+
return style.strong(text);
|
|
1401
|
+
}
|
|
1402
|
+
return style.quiet(text);
|
|
1403
|
+
}
|
|
1404
|
+
function wrapped(text, width) {
|
|
1405
|
+
const lines = [];
|
|
1406
|
+
let line2 = "";
|
|
1407
|
+
for (const word of text.split(/\s+/).filter((part) => part !== "")) {
|
|
1408
|
+
if (line2 === "") {
|
|
1409
|
+
line2 = word;
|
|
1410
|
+
} else if (line2.length + 1 + word.length <= width) {
|
|
1411
|
+
line2 = `${line2} ${word}`;
|
|
1412
|
+
} else {
|
|
1413
|
+
lines.push(line2);
|
|
1414
|
+
line2 = word;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
if (line2 !== "") {
|
|
1418
|
+
lines.push(line2);
|
|
1419
|
+
}
|
|
1420
|
+
return lines;
|
|
1421
|
+
}
|
|
1422
|
+
function truncate3(text, limit) {
|
|
1423
|
+
return text.length <= limit ? text : `${text.slice(0, limit - 3)}...`;
|
|
1424
|
+
}
|
|
1425
|
+
function summarise2(actions) {
|
|
1426
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1427
|
+
for (const action of actions) {
|
|
1428
|
+
counts.set(action.status, (counts.get(action.status) ?? 0) + 1);
|
|
1429
|
+
}
|
|
1430
|
+
const parts = [...counts].sort(([a], [b]) => a < b ? -1 : 1).map(([k, v]) => `${String(v)} ${k}`);
|
|
1431
|
+
const undoable = actions.filter((a) => a.inverse !== void 0).length;
|
|
1432
|
+
return `${String(actions.length)} actions: ${parts.join(", ")} | ${String(undoable)} with a recorded undo`;
|
|
1433
|
+
}
|
|
1434
|
+
var journalArg = "";
|
|
1435
|
+
function runClose(argv, journal) {
|
|
1436
|
+
const active = journal.listRuns().filter((candidate) => candidate.status === "active");
|
|
1437
|
+
const run = pick([...active].reverse(), positional(argv)[1], RUN, true);
|
|
1438
|
+
const closed = journal.closeAbandonedRun(run.id);
|
|
1439
|
+
out("");
|
|
1440
|
+
out(
|
|
1441
|
+
closed ? ` ${style.label("closed")} ${style.strong(run.id)}` : ` ${style.quiet(`${run.id} was not active.`)}`
|
|
1442
|
+
);
|
|
1443
|
+
out("");
|
|
1444
|
+
return closed ? 0 : 1;
|
|
1445
|
+
}
|
|
1446
|
+
function runGates(journal, asJson) {
|
|
1447
|
+
const waiting = journal.listGated();
|
|
1448
|
+
if (asJson) {
|
|
1449
|
+
out(JSON.stringify(waiting));
|
|
1450
|
+
return 0;
|
|
1451
|
+
}
|
|
1452
|
+
if (waiting.length === 0) {
|
|
1453
|
+
out("");
|
|
1454
|
+
out(` ${style.quiet("Nothing is waiting for a decision.")}`);
|
|
1455
|
+
out("");
|
|
1456
|
+
return 0;
|
|
1457
|
+
}
|
|
1458
|
+
out("");
|
|
1459
|
+
out(` ${style.label("awaiting approval")}`);
|
|
1460
|
+
out(` ${rule(72)}`);
|
|
1461
|
+
out("");
|
|
1462
|
+
for (const action of waiting) {
|
|
1463
|
+
out(` ${style.strong(action.id)} ${style.quiet(action.ts)}`);
|
|
1464
|
+
out(` ${style.accent(`${action.server}.${action.tool}`)} ${style.quiet(truncate3(JSON.stringify(action.args), 88))}`);
|
|
1465
|
+
for (const [at, line2] of wrapped(action.error ?? "held by policy", 76).entries()) {
|
|
1466
|
+
out(` ${at === 0 ? style.quiet(action.class) : " ".repeat(action.class.length)} ${style.quiet(line2)}`);
|
|
1467
|
+
}
|
|
1468
|
+
out("");
|
|
1469
|
+
}
|
|
1470
|
+
const self = cliCommand();
|
|
1471
|
+
out(
|
|
1472
|
+
` ${style.quiet(`${self} approve`)} ${style.accent(waiting[0]?.id.slice(0, 8) ?? "<id>")} ${style.quiet(`--by <name>${journalArg}`)}`
|
|
1473
|
+
);
|
|
1474
|
+
out(` ${style.quiet(`${self} approve --all --by <name>${journalArg}`)}`);
|
|
1475
|
+
out("");
|
|
1476
|
+
return 0;
|
|
1477
|
+
}
|
|
1478
|
+
function runDecision(argv, journal, approving) {
|
|
1479
|
+
const waiting = journal.listGated();
|
|
1480
|
+
const given = positional(argv)[1];
|
|
1481
|
+
const by = flag(argv, "--by") ?? process.env["USER"] ?? process.env["LOGNAME"] ?? "unknown";
|
|
1482
|
+
const reason = flag(argv, "--reason") ?? "denied by operator";
|
|
1483
|
+
if (given !== void 0) {
|
|
1484
|
+
const settled = journal.getAction(given);
|
|
1485
|
+
if (settled !== void 0 && settled.status !== "gated") {
|
|
1486
|
+
process.stderr.write(
|
|
1487
|
+
`synartesis: ${given} is no longer awaiting approval (it is ${settled.status})
|
|
1488
|
+
`
|
|
1489
|
+
);
|
|
1490
|
+
return 1;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
const targets = argv.includes("--all") ? waiting : [pick(waiting, given, WAITING)];
|
|
1494
|
+
if (targets.length === 0) {
|
|
1495
|
+
out("nothing is awaiting approval");
|
|
1496
|
+
return 0;
|
|
1497
|
+
}
|
|
1498
|
+
let failed = 0;
|
|
1499
|
+
for (const action of targets) {
|
|
1500
|
+
const changed = approving ? journal.approve(action.id, by) : journal.deny(action.id, by, reason);
|
|
1501
|
+
if (!changed) {
|
|
1502
|
+
const now = journal.getAction(action.id);
|
|
1503
|
+
process.stderr.write(
|
|
1504
|
+
`synartesis: ${action.id} is no longer awaiting approval (it is ${now?.status ?? "gone"})
|
|
1505
|
+
`
|
|
1506
|
+
);
|
|
1507
|
+
failed += 1;
|
|
1508
|
+
continue;
|
|
1509
|
+
}
|
|
1510
|
+
out(
|
|
1511
|
+
` ${style.accent(approving ? "approved" : "denied")} ${style.strong(`${action.server}.${action.tool}`)} ${style.quiet(action.id)}`
|
|
1512
|
+
);
|
|
1513
|
+
}
|
|
1514
|
+
return failed === 0 ? 0 : 1;
|
|
1515
|
+
}
|
|
1516
|
+
function report(result) {
|
|
1517
|
+
out("");
|
|
1518
|
+
out(` ${style.label(result.dryRun ? "dry run" : "undo")} ${style.strong(result.runId)}`);
|
|
1519
|
+
out(` ${rule(72)}`);
|
|
1520
|
+
out("");
|
|
1521
|
+
for (const step of result.steps) {
|
|
1522
|
+
const unverified = step.kind === "revert" && !step.verified ? ` ${style.accent("[unverified]")}` : "";
|
|
1523
|
+
const kind = step.kind === "halt" || step.kind === "permanent" ? style.accent(step.kind.padEnd(16)) : step.kind.padEnd(16);
|
|
1524
|
+
out(
|
|
1525
|
+
` ${style.quiet(String(step.seq).padStart(3))} ${kind} ${style.strong(`${step.server}.${step.tool}`)} ${style.quiet(step.reason)}${unverified}`
|
|
1526
|
+
);
|
|
1527
|
+
if (step.plan !== void 0 && step.kind === "revert") {
|
|
1528
|
+
const verb = `${step.replanned === true ? "replanned, " : ""}${result.dryRun ? "would call" : "called"}`;
|
|
1529
|
+
out(
|
|
1530
|
+
` ${style.quiet(verb)} ${step.plan.server}.${step.plan.tool} ` + style.quiet(truncate3(JSON.stringify(step.plan.args), 120))
|
|
1531
|
+
);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
if (result.halted !== void 0) {
|
|
1535
|
+
out("");
|
|
1536
|
+
out(
|
|
1537
|
+
` ${style.accent("halted")} ${style.quiet(`at sequence ${String(result.halted.seq)}`)} ${result.halted.reason}`
|
|
1538
|
+
);
|
|
1539
|
+
if (result.halted.detail !== "") {
|
|
1540
|
+
for (const line2 of result.halted.detail.split("\n")) {
|
|
1541
|
+
out(` ${style.quiet(line2)}`);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
const permanent = result.steps.filter((step) => step.kind === "permanent");
|
|
1546
|
+
if (permanent.length > 0) {
|
|
1547
|
+
out("");
|
|
1548
|
+
out(
|
|
1549
|
+
` ${style.quiet(`${String(permanent.length)} action${permanent.length === 1 ? "" : "s"} could not be undone and ${permanent.length === 1 ? "was" : "were"} left in place.`)}`
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
out("");
|
|
1553
|
+
out(
|
|
1554
|
+
` ${style.label("result")} ${result.status === "rolled_back" ? result.status : style.accent(result.status)}`
|
|
1555
|
+
);
|
|
1556
|
+
out("");
|
|
1557
|
+
return result.status === "rolled_back" ? 0 : 1;
|
|
1558
|
+
}
|
|
1559
|
+
async function performUndo(manifestPath, journal, runId, options) {
|
|
1560
|
+
const manifest = loadManifest(manifestPath);
|
|
1561
|
+
const upstreams = [];
|
|
1562
|
+
try {
|
|
1563
|
+
for (const [name, spec] of Object.entries(manifest.servers)) {
|
|
1564
|
+
upstreams.push(
|
|
1565
|
+
await connectStdioUpstream({
|
|
1566
|
+
name,
|
|
1567
|
+
command: spec.command,
|
|
1568
|
+
args: spec.args,
|
|
1569
|
+
stderr: "capture",
|
|
1570
|
+
...spec.env === void 0 ? {} : { env: spec.env }
|
|
1571
|
+
})
|
|
1572
|
+
);
|
|
1573
|
+
}
|
|
1574
|
+
return await rollback({
|
|
1575
|
+
journal,
|
|
1576
|
+
router: createRouter(upstreams, manifest),
|
|
1577
|
+
runId,
|
|
1578
|
+
...options.toSeq === void 0 ? {} : { toSeq: options.toSeq },
|
|
1579
|
+
dryRun: options.dryRun,
|
|
1580
|
+
...options.replan === true ? { replanWith: manifest } : {}
|
|
1581
|
+
});
|
|
1582
|
+
} finally {
|
|
1583
|
+
for (const upstream of upstreams) {
|
|
1584
|
+
await upstream.close();
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
async function runUndo(argv, journal) {
|
|
1589
|
+
const runId = pick([...journal.listRuns()].reverse(), positional(argv)[1], RUN, true).id;
|
|
1590
|
+
const rawTo = flag(argv, "--to");
|
|
1591
|
+
const toSeq = rawTo === void 0 ? void 0 : Number(rawTo);
|
|
1592
|
+
if (toSeq !== void 0 && (!Number.isInteger(toSeq) || toSeq < 1)) {
|
|
1593
|
+
throw new UsageError("--to needs a positive whole number");
|
|
1594
|
+
}
|
|
1595
|
+
if (toSeq !== void 0) {
|
|
1596
|
+
const highest = journal.getActions(runId).reduce((top, action) => Math.max(top, action.seq), 0);
|
|
1597
|
+
if (toSeq > highest) {
|
|
1598
|
+
throw new UsageError(
|
|
1599
|
+
`--to ${String(toSeq)} is past the end of this run, which goes up to ${String(highest)}`
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
return report(
|
|
1604
|
+
await performUndo(findManifest(flag(argv, "--manifest")), journal, runId, {
|
|
1605
|
+
dryRun: argv.includes("--dry-run"),
|
|
1606
|
+
...toSeq === void 0 ? {} : { toSeq },
|
|
1607
|
+
replan: argv.includes("--replan")
|
|
1608
|
+
})
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
async function main(argv) {
|
|
1612
|
+
const command = positional(argv)[0];
|
|
1613
|
+
if (command === "proxy") {
|
|
1614
|
+
await import("./proxy.js");
|
|
1615
|
+
return 0;
|
|
1616
|
+
}
|
|
1617
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
1618
|
+
process.stdout.write(`${banner()}
|
|
1619
|
+
${COMMANDS}`);
|
|
1620
|
+
return 0;
|
|
1621
|
+
}
|
|
1622
|
+
if (command === void 0) {
|
|
1623
|
+
const manifestPath = findManifest(flag(argv, "--manifest"));
|
|
1624
|
+
const journalPath2 = findJournal(flag(argv, "--journal"), manifestPath);
|
|
1625
|
+
return await openConsole({
|
|
1626
|
+
journalPath: journalPath2,
|
|
1627
|
+
write: (text) => process.stdout.write(text),
|
|
1628
|
+
live: process.stdout.isTTY,
|
|
1629
|
+
decideAs: flag(argv, "--by") ?? process.env["USER"] ?? process.env["LOGNAME"] ?? "unknown",
|
|
1630
|
+
undo: async (runId, dryRun) => {
|
|
1631
|
+
const journal2 = openJournal(journalPath2, { mustExist: true });
|
|
1632
|
+
try {
|
|
1633
|
+
return await performUndo(manifestPath, journal2, runId, { dryRun });
|
|
1634
|
+
} finally {
|
|
1635
|
+
journal2.close();
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
if (command === "init") {
|
|
1641
|
+
return await runInit(argv);
|
|
1642
|
+
}
|
|
1643
|
+
if (command === "check") {
|
|
1644
|
+
return await runCheck(argv);
|
|
1645
|
+
}
|
|
1646
|
+
const asJson = argv.includes("--json");
|
|
1647
|
+
const given = flag(argv, "--journal");
|
|
1648
|
+
const journalPath = findJournal(given, findManifest(flag(argv, "--manifest")));
|
|
1649
|
+
if (command === "watch") {
|
|
1650
|
+
const live = process.stdout.isTTY && !argv.includes("--once");
|
|
1651
|
+
return await watch({
|
|
1652
|
+
journalPath,
|
|
1653
|
+
approveWith: cliCommand(),
|
|
1654
|
+
write: (text) => process.stdout.write(text),
|
|
1655
|
+
live,
|
|
1656
|
+
// A decision has to be attributable, so the view can only make one when
|
|
1657
|
+
// it knows whose it is.
|
|
1658
|
+
decideAs: flag(argv, "--by") ?? process.env["USER"] ?? process.env["LOGNAME"] ?? "unknown"
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
journalArg = given === void 0 ? "" : ` --journal ${resolve(given)}`;
|
|
1662
|
+
const journal = openJournal(journalPath, { mustExist: true });
|
|
1663
|
+
try {
|
|
1664
|
+
switch (command) {
|
|
1665
|
+
case "list":
|
|
1666
|
+
return runList(journal, asJson);
|
|
1667
|
+
case "show":
|
|
1668
|
+
return runShow(argv, journal, asJson);
|
|
1669
|
+
case "close":
|
|
1670
|
+
return runClose(argv, journal);
|
|
1671
|
+
case "gates":
|
|
1672
|
+
return runGates(journal, asJson);
|
|
1673
|
+
case "approve":
|
|
1674
|
+
return runDecision(argv, journal, true);
|
|
1675
|
+
case "deny":
|
|
1676
|
+
return runDecision(argv, journal, false);
|
|
1677
|
+
case "undo":
|
|
1678
|
+
return await runUndo(argv, journal);
|
|
1679
|
+
default:
|
|
1680
|
+
throw new UsageError(`unknown command ${command}`);
|
|
1681
|
+
}
|
|
1682
|
+
} finally {
|
|
1683
|
+
journal.close();
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
try {
|
|
1687
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
1688
|
+
} catch (error) {
|
|
1689
|
+
if (error instanceof UsageError) {
|
|
1690
|
+
process.stderr.write(`synartesis: ${error.message}
|
|
1691
|
+
|
|
1692
|
+
${COMMANDS}`);
|
|
1693
|
+
process.exitCode = 2;
|
|
1694
|
+
} else if (error instanceof ManifestError) {
|
|
1695
|
+
process.stderr.write(`synartesis: ${error.message}
|
|
1696
|
+
`);
|
|
1697
|
+
process.exitCode = 2;
|
|
1698
|
+
} else if (error instanceof SynartesisError) {
|
|
1699
|
+
process.stderr.write(`synartesis: ${error.message}
|
|
1700
|
+
`);
|
|
1701
|
+
process.exitCode = 1;
|
|
1702
|
+
} else {
|
|
1703
|
+
process.stderr.write(`synartesis: ${describe(error)}
|
|
1704
|
+
`);
|
|
1705
|
+
process.exitCode = 1;
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
//# sourceMappingURL=cli.js.map
|