@imenam/mcp-ssh-gate 0.0.0-stage → 1.0.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/README.md +128 -2
- package/dist/shared/model.d.ts +76 -0
- package/dist/shared/model.js +7 -0
- package/dist/src/cli/setup-mcp.d.ts +21 -0
- package/dist/src/cli/setup-mcp.js +137 -0
- package/dist/src/core/config.d.ts +14 -0
- package/dist/src/core/config.js +36 -0
- package/dist/src/core/errors.d.ts +4 -0
- package/dist/src/core/errors.js +4 -0
- package/dist/src/core/executor.d.ts +46 -0
- package/dist/src/core/executor.js +328 -0
- package/dist/src/core/files.d.ts +9 -0
- package/dist/src/core/files.js +63 -0
- package/dist/src/core/heartbeat.d.ts +5 -0
- package/dist/src/core/heartbeat.js +26 -0
- package/dist/src/core/request-store.d.ts +26 -0
- package/dist/src/core/request-store.js +115 -0
- package/dist/src/core/server-store.d.ts +42 -0
- package/dist/src/core/server-store.js +177 -0
- package/dist/src/core/ssh-session.d.ts +72 -0
- package/dist/src/core/ssh-session.js +243 -0
- package/dist/src/core/vault.d.ts +8 -0
- package/dist/src/core/vault.js +45 -0
- package/dist/src/gui-worker.d.ts +1 -0
- package/dist/src/gui-worker.js +212 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +149 -0
- package/dist/src/tools/format.d.ts +4 -0
- package/dist/src/tools/format.js +66 -0
- package/dist/src/tools/tools.d.ts +14 -0
- package/dist/src/tools/tools.js +247 -0
- package/dist/src/version.d.ts +1 -0
- package/dist/src/version.js +16 -0
- package/gui/dist/assets/index-B2ykxkbY.css +1 -0
- package/gui/dist/assets/index-D1Fg5r8H.js +10 -0
- package/gui/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/gui/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/gui/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/gui/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/gui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/gui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/gui/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/gui/dist/index.html +13 -0
- package/package.json +58 -4
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { MAX_COMMANDS_PER_REQUEST, isFinalStatus } from "../../shared/model.js";
|
|
3
|
+
import { GateError } from "../core/errors.js";
|
|
4
|
+
import { formatRequest } from "./format.js";
|
|
5
|
+
const RESULT_OUTPUT_PER_COMMAND = 20_000;
|
|
6
|
+
const HISTORY_OUTPUT_PER_COMMAND = 2_000;
|
|
7
|
+
const DEFAULT_WAIT_SECONDS = 300;
|
|
8
|
+
const MAX_WAIT_SECONDS = 1800;
|
|
9
|
+
const POLL_MS = 1000;
|
|
10
|
+
const GUI_CHECK_EVERY_MS = 10_000;
|
|
11
|
+
export const TOOL_DEFINITIONS = [
|
|
12
|
+
{
|
|
13
|
+
name: "list_servers",
|
|
14
|
+
description: "List the SSH servers configured in the GUI, with their user, host, whether their connection has been tested, and their notes: the context gathered on each server (where the sites live, how apps are managed, rules to follow). " +
|
|
15
|
+
"Read them before submitting commands. Commands can only be submitted to a tested server.",
|
|
16
|
+
inputSchema: { type: "object", properties: {} },
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: "update_server_notes",
|
|
20
|
+
description: `Record context about a server so that later sessions do not have to rediscover it: where the websites live, how the apps are run (PM2, systemd, Docker…), config locations, rules the user gave. ` +
|
|
21
|
+
`Keep notes short and factual, one fact per line. mode "append" (default) adds lines at the end; mode "replace" rewrites the whole text: start from the current notes returned by list_servers and keep every line the user wrote. ` +
|
|
22
|
+
`The user sees and can edit the notes in the GUI.`,
|
|
23
|
+
inputSchema: {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
server: { type: "string", description: "Server id or name." },
|
|
27
|
+
notes: { type: "string", description: "Lines to add, or the full new text with mode \"replace\"." },
|
|
28
|
+
mode: { type: "string", enum: ["append", "replace"], description: "\"append\" (default) or \"replace\"." },
|
|
29
|
+
},
|
|
30
|
+
required: ["server", "notes"],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "submit_commands",
|
|
35
|
+
description: `Submit shell commands to run on a server. Read the server notes from list_servers first. Nothing runs until the user approves them in the web GUI: they can edit or remove a command, add a note, or refuse the whole request. ` +
|
|
36
|
+
`Approved commands run in order in a persistent bash shell (cd and export carry over between commands and between requests), without a terminal: stdin is /dev/null and stderr is merged into stdout, so interactive prompts get end-of-file and sudo must not ask for a password. ` +
|
|
37
|
+
`Execution stops at the first command that exits non-zero. Returns a request_id: call wait_for_result with it. ` +
|
|
38
|
+
`Give 1 to ${MAX_COMMANDS_PER_REQUEST} commands and explain what you want to achieve: the user reads the explanation before approving.`,
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: {
|
|
42
|
+
server: { type: "string", description: "Server id or name, as returned by list_servers." },
|
|
43
|
+
commands: {
|
|
44
|
+
type: "array",
|
|
45
|
+
items: { type: "string" },
|
|
46
|
+
minItems: 1,
|
|
47
|
+
maxItems: MAX_COMMANDS_PER_REQUEST,
|
|
48
|
+
description: "Commands to run in order. One entry per command; a single entry may span several lines.",
|
|
49
|
+
},
|
|
50
|
+
explanation: { type: "string", description: "Why these commands are needed and what they do, shown to the user above the commands." },
|
|
51
|
+
},
|
|
52
|
+
required: ["server", "commands", "explanation"],
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "wait_for_result",
|
|
57
|
+
description: `Wait until the user has decided on a request and its commands have finished, then return the outcome: refusal and its reason, or each command with its exit code and output, and the user's note. ` +
|
|
58
|
+
`Returns earlier with the current status when timeout_seconds elapses; call it again to keep waiting.`,
|
|
59
|
+
inputSchema: {
|
|
60
|
+
type: "object",
|
|
61
|
+
properties: {
|
|
62
|
+
request_id: { type: "string", description: "Id returned by submit_commands." },
|
|
63
|
+
timeout_seconds: {
|
|
64
|
+
type: "number",
|
|
65
|
+
description: `How long to wait, in seconds (default ${DEFAULT_WAIT_SECONDS}, max ${MAX_WAIT_SECONDS}).`,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
required: ["request_id"],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "cancel_request",
|
|
73
|
+
description: "Withdraw a request that the user has not approved yet. A request that is already running can only be interrupted by the user.",
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: "object",
|
|
76
|
+
properties: { request_id: { type: "string", description: "Id returned by submit_commands." } },
|
|
77
|
+
required: ["request_id"],
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "get_history",
|
|
82
|
+
description: "Read the latest requests on a server, newest last: commands submitted by agents and commands the user typed in the GUI, with their status, exit code and the end of their output.",
|
|
83
|
+
inputSchema: {
|
|
84
|
+
type: "object",
|
|
85
|
+
properties: {
|
|
86
|
+
server: { type: "string", description: "Server id or name." },
|
|
87
|
+
limit: { type: "number", description: "Number of requests to return (default 10, max 50)." },
|
|
88
|
+
},
|
|
89
|
+
required: ["server"],
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: "reconnect_gui",
|
|
94
|
+
description: "Start the web GUI again if it is not running, and return its URL.",
|
|
95
|
+
inputSchema: { type: "object", properties: {} },
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
const SubmitArgs = z.object({
|
|
99
|
+
server: z.string().min(1),
|
|
100
|
+
commands: z.array(z.string()).min(1).max(MAX_COMMANDS_PER_REQUEST),
|
|
101
|
+
explanation: z.string().trim().min(1, "explanation must not be empty"),
|
|
102
|
+
});
|
|
103
|
+
const WaitArgs = z.object({
|
|
104
|
+
request_id: z.string().min(1),
|
|
105
|
+
timeout_seconds: z.number().positive().max(MAX_WAIT_SECONDS).optional(),
|
|
106
|
+
});
|
|
107
|
+
const RequestArgs = z.object({ request_id: z.string().min(1) });
|
|
108
|
+
const NotesArgs = z.object({
|
|
109
|
+
server: z.string().min(1),
|
|
110
|
+
notes: z.string(),
|
|
111
|
+
mode: z.enum(["append", "replace"]).optional(),
|
|
112
|
+
});
|
|
113
|
+
const HistoryArgs = z.object({
|
|
114
|
+
server: z.string().min(1),
|
|
115
|
+
limit: z.number().int().min(1).max(50).optional(),
|
|
116
|
+
});
|
|
117
|
+
function text(value) {
|
|
118
|
+
return { content: [{ type: "text", text: value }] };
|
|
119
|
+
}
|
|
120
|
+
function parse(schema, args) {
|
|
121
|
+
const result = schema.safeParse(args ?? {});
|
|
122
|
+
if (!result.success) {
|
|
123
|
+
throw new GateError(`Invalid arguments: ${result.error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; ")}`);
|
|
124
|
+
}
|
|
125
|
+
return result.data;
|
|
126
|
+
}
|
|
127
|
+
function serverOrNull(ctx, id) {
|
|
128
|
+
try {
|
|
129
|
+
return ctx.servers.get(id);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function sleep(ms, signal) {
|
|
136
|
+
return new Promise((resolve) => {
|
|
137
|
+
const timer = setTimeout(resolve, ms);
|
|
138
|
+
signal?.addEventListener("abort", () => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
resolve();
|
|
141
|
+
}, { once: true });
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
function listServers(ctx) {
|
|
145
|
+
const servers = ctx.servers.list();
|
|
146
|
+
if (servers.length === 0) {
|
|
147
|
+
const url = ctx.guiUrl();
|
|
148
|
+
return text(`No server is configured yet. Ask the user to add one in the GUI${url ? ` (${url})` : ""}.`);
|
|
149
|
+
}
|
|
150
|
+
const blocks = servers.map((s) => {
|
|
151
|
+
const head = `- ${s.id} — "${s.name}" — ${s.username}@${s.host}:${s.port} — ${s.hostKeyFingerprint ? "ready" : "connection not tested yet: commands cannot be submitted"}`;
|
|
152
|
+
if (!s.notes)
|
|
153
|
+
return `${head}\n Notes: none yet. Record what you learn about this server with update_server_notes.`;
|
|
154
|
+
const author = s.notesUpdatedBy === "user" ? "the user" : "an agent";
|
|
155
|
+
const indented = s.notes.split("\n").map((line) => ` ${line}`).join("\n");
|
|
156
|
+
return `${head}\n Notes (last updated by ${author} on ${s.notesUpdatedAt}):\n${indented}`;
|
|
157
|
+
});
|
|
158
|
+
return text(blocks.join("\n\n"));
|
|
159
|
+
}
|
|
160
|
+
function updateServerNotes(ctx, args) {
|
|
161
|
+
const { server: serverRef, notes, mode } = parse(NotesArgs, args);
|
|
162
|
+
const server = ctx.servers.get(serverRef);
|
|
163
|
+
const addition = notes.trim();
|
|
164
|
+
if (!addition && mode !== "replace")
|
|
165
|
+
throw new GateError("notes must not be empty.");
|
|
166
|
+
const next = mode === "replace" || !server.notes ? addition : `${server.notes}\n${addition}`;
|
|
167
|
+
const saved = ctx.servers.setNotes(server.id, next, "agent");
|
|
168
|
+
return text(`Notes of ${saved.id} saved (${saved.notes.length} characters). The user sees them in the GUI.`);
|
|
169
|
+
}
|
|
170
|
+
function submitCommands(ctx, args) {
|
|
171
|
+
const { server: serverRef, commands, explanation } = parse(SubmitArgs, args);
|
|
172
|
+
const server = ctx.servers.get(serverRef);
|
|
173
|
+
if (!server.hostKeyFingerprint) {
|
|
174
|
+
throw new GateError(`The connection to "${server.id}" has never been tested. Ask the user to run "Tester la connexion" for this server in the GUI first.`);
|
|
175
|
+
}
|
|
176
|
+
const request = ctx.requests.create({ serverId: server.id, source: "agent", explanation, commands });
|
|
177
|
+
ctx.ensureGui();
|
|
178
|
+
const url = ctx.guiUrl();
|
|
179
|
+
return text(`Request ${request.id} submitted to ${server.id} with ${request.commands.length} command(s). It is waiting for the user's approval${url ? ` in the GUI: ${url}` : " in the GUI"}.\n` +
|
|
180
|
+
`Call wait_for_result with request_id "${request.id}".`);
|
|
181
|
+
}
|
|
182
|
+
async function waitForResult(ctx, args, signal) {
|
|
183
|
+
const { request_id, timeout_seconds } = parse(WaitArgs, args);
|
|
184
|
+
const deadline = Date.now() + (timeout_seconds ?? DEFAULT_WAIT_SECONDS) * 1000;
|
|
185
|
+
let nextGuiCheck = 0;
|
|
186
|
+
for (;;) {
|
|
187
|
+
const request = ctx.requests.get(request_id);
|
|
188
|
+
if (isFinalStatus(request.status)) {
|
|
189
|
+
return text(formatRequest(request, serverOrNull(ctx, request.serverId), RESULT_OUTPUT_PER_COMMAND));
|
|
190
|
+
}
|
|
191
|
+
if (Date.now() >= nextGuiCheck) {
|
|
192
|
+
ctx.ensureGui();
|
|
193
|
+
nextGuiCheck = Date.now() + GUI_CHECK_EVERY_MS;
|
|
194
|
+
}
|
|
195
|
+
if (Date.now() >= deadline || signal?.aborted) {
|
|
196
|
+
const state = request.status === "pending" ? "still waiting for the user's approval" : "still running";
|
|
197
|
+
return text(`Request ${request.id} is ${state}. Call wait_for_result again to keep waiting.`);
|
|
198
|
+
}
|
|
199
|
+
await sleep(Math.min(POLL_MS, Math.max(0, deadline - Date.now())), signal);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function cancelRequest(ctx, args) {
|
|
203
|
+
const { request_id } = parse(RequestArgs, args);
|
|
204
|
+
const request = ctx.requests.update(request_id, (draft) => {
|
|
205
|
+
if (draft.status !== "pending") {
|
|
206
|
+
throw new GateError(`Request ${draft.id} cannot be cancelled: it is ${draft.status}.`);
|
|
207
|
+
}
|
|
208
|
+
draft.status = "cancelled";
|
|
209
|
+
draft.finishedAt = new Date().toISOString();
|
|
210
|
+
for (const command of draft.commands)
|
|
211
|
+
command.status = "skipped";
|
|
212
|
+
});
|
|
213
|
+
return text(`Request ${request.id} cancelled.`);
|
|
214
|
+
}
|
|
215
|
+
function getHistory(ctx, args) {
|
|
216
|
+
const { server: serverRef, limit } = parse(HistoryArgs, args);
|
|
217
|
+
const server = ctx.servers.get(serverRef);
|
|
218
|
+
const requests = ctx.requests.listForServer(server.id, limit ?? 10);
|
|
219
|
+
if (requests.length === 0)
|
|
220
|
+
return text(`No request on ${server.id} yet.`);
|
|
221
|
+
return text(requests.map((request) => formatRequest(request, server, HISTORY_OUTPUT_PER_COMMAND)).join("\n\n---\n\n"));
|
|
222
|
+
}
|
|
223
|
+
export async function callTool(ctx, name, args, signal) {
|
|
224
|
+
try {
|
|
225
|
+
switch (name) {
|
|
226
|
+
case "list_servers":
|
|
227
|
+
return listServers(ctx);
|
|
228
|
+
case "update_server_notes":
|
|
229
|
+
return updateServerNotes(ctx, args);
|
|
230
|
+
case "submit_commands":
|
|
231
|
+
return submitCommands(ctx, args);
|
|
232
|
+
case "wait_for_result":
|
|
233
|
+
return await waitForResult(ctx, args, signal);
|
|
234
|
+
case "cancel_request":
|
|
235
|
+
return cancelRequest(ctx, args);
|
|
236
|
+
case "get_history":
|
|
237
|
+
return getHistory(ctx, args);
|
|
238
|
+
case "reconnect_gui":
|
|
239
|
+
return await ctx.reconnectGui();
|
|
240
|
+
default:
|
|
241
|
+
throw new GateError(`Unknown tool: ${name}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const APP_VERSION: string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
// Lit la version dans le premier package.json trouvé en remontant depuis ce module.
|
|
5
|
+
function readPackageVersion() {
|
|
6
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
while (!existsSync(path.join(dir, "package.json"))) {
|
|
8
|
+
const parent = path.dirname(dir);
|
|
9
|
+
if (parent === dir)
|
|
10
|
+
throw new Error("package.json introuvable");
|
|
11
|
+
dir = parent;
|
|
12
|
+
}
|
|
13
|
+
const pkg = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
14
|
+
return pkg.version;
|
|
15
|
+
}
|
|
16
|
+
export const APP_VERSION = readPackageVersion();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(./inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2)format("woff2-variations");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(./inter-cyrillic-wght-normal-DqGufNeO.woff2)format("woff2-variations");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(./inter-greek-ext-wght-normal-DlzME5K_.woff2)format("woff2-variations");unicode-range:U+1F??}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(./inter-greek-wght-normal-CkhJZR-_.woff2)format("woff2-variations");unicode-range:U+370-377,U+37A-37F,U+384-38A,U+38C,U+38E-3A1,U+3A3-3FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(./inter-vietnamese-wght-normal-CBcvBZtf.woff2)format("woff2-variations");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(./inter-latin-ext-wght-normal-DO1Apj_S.woff2)format("woff2-variations");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(./inter-latin-wght-normal-Dx4kXJAl.woff2)format("woff2-variations");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(data:font/woff2;base64,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)format("woff2-variations");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(./jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2)format("woff2-variations");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(./jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2)format("woff2-variations");unicode-range:U+370-377,U+37A-37F,U+384-38A,U+38C,U+38E-3A1,U+3A3-3FF}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(./jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2)format("woff2-variations");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(./jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2)format("woff2-variations");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(./jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2)format("woff2-variations");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--bg:#0c0e14;--panel:#0f1118;--surface:#13161f;--surface-raised:#171b26;--surface-current:#1f2540;--terminal:#090b10;--border:#20242f;--border-strong:#2a2f3d;--divider:#1c2029;--text:#eceef3;--text-soft:#c9cedb;--muted:#9aa1b1;--faint:#646b7b;--idle:#3a4050;--accent:#5b6cf5;--accent-hover:#6878ff;--accent-soft:#8b97ff;--ok:#34d399;--ok-text:#4ade80;--ok-bg:#10261f;--danger:#f0857d;--danger-border:#5c2a2a;--warning:#e3a33b;--warning-bg:#2a2010;--font-sans:"Inter Variable", "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;--font-mono:"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, Consolas, monospace;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;font-family:var(--font-sans);color:var(--text);-webkit-font-smoothing:antialiased;font-size:14px}*{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{background:var(--bg)}button{font:inherit;color:inherit;cursor:pointer;background:0 0;border:none;padding:0}button:disabled{cursor:default;opacity:.5}input,textarea{font:inherit;color:var(--text)}.mono,.entry-cmd,.cmd-text,.cmd-edit,.terminal,.keybox,.oneliner,.prompt input{font-variant-ligatures:none;font-feature-settings:"liga" 0, "calt" 0}p{margin:0}.mono{font-family:var(--font-mono)}.app{grid-template-columns:260px 1fr;height:100%;display:grid}.sidebar{background:var(--panel);border-right:1px solid var(--border);flex-direction:column;min-height:0;padding:24px 12px;display:flex}.brand{align-items:center;gap:10px;padding:0 12px;font-size:16px;font-weight:700;display:flex}.brand svg{color:var(--accent-soft)}.side-label{letter-spacing:.04em;color:var(--faint);margin:36px 12px 10px;font-size:11px;font-weight:600}.server-list{flex-direction:column;flex:1;gap:4px;display:flex;overflow-y:auto}.server-row{text-align:left;border-radius:8px;grid-template-columns:8px 1fr auto;align-items:center;gap:12px;width:100%;padding:8px 12px 8px 14px;display:grid}.server-row:hover{background:var(--surface-raised)}.server-row.selected{background:var(--surface-current)}.server-name{color:var(--text-soft);white-space:nowrap;text-overflow:ellipsis;font-size:14px;overflow:hidden}.server-row.selected .server-name{color:var(--text);font-weight:600}.server-sub{color:var(--faint);white-space:nowrap;text-overflow:ellipsis;margin-top:2px;font-size:11px;overflow:hidden}.dot{background:var(--idle);border-radius:50%;width:8px;height:8px}.dot.on{background:var(--ok)}.badge{background:var(--accent);color:#fff;border-radius:10px;justify-content:center;align-items:center;min-width:28px;height:20px;padding:0 8px;font-size:11px;font-weight:700;display:inline-flex}.main{flex-direction:column;min-width:0;min-height:0;display:flex}.topbar{border-bottom:1px solid var(--border);flex-shrink:0;align-items:center;gap:16px;height:72px;padding:0 40px;display:flex}.topbar-title{font-size:18px;font-weight:700}.topbar-sub{color:var(--faint);margin-top:4px;font-size:12px}.topbar-spacer{flex:1}.chip{background:var(--surface-raised);height:28px;color:var(--muted);border-radius:14px;align-items:center;gap:8px;padding:0 14px;font-size:12px;font-weight:500;display:inline-flex}.chip.ok{background:var(--ok-bg);color:var(--ok-text)}.icon-btn{width:32px;height:32px;color:var(--muted);border-radius:8px;justify-content:center;align-items:center;display:inline-flex}.icon-btn:hover:not(:disabled){background:var(--surface-raised);color:var(--text)}.menu-anchor{position:relative}.menu{z-index:20;background:var(--surface);border:1px solid var(--border-strong);border-radius:10px;min-width:220px;padding:6px;position:absolute;top:38px;right:0;box-shadow:0 12px 32px #00000073}.menu button{width:100%;color:var(--text-soft);text-align:left;border-radius:6px;align-items:center;gap:10px;padding:8px 10px;display:flex}.menu button:hover{background:var(--surface-raised)}.menu button.danger{color:var(--danger)}.content{flex-direction:column;flex:1;gap:8px;padding:24px 40px 16px;display:flex;overflow-y:auto}.footer{flex-shrink:0;padding:12px 40px 24px}.entry{background:var(--surface);border:1px solid var(--border);border-radius:8px}.entry-head{text-align:left;grid-template-columns:48px 48px 1fr auto 18px;align-items:center;gap:8px;width:100%;min-height:44px;padding:0 16px;display:grid}.entry-actor{color:var(--muted);font-size:12px}.entry-time{color:var(--faint);font-size:12px}.entry-cmd{font-family:var(--font-mono);color:var(--text);white-space:nowrap;text-overflow:ellipsis;font-size:13px;overflow:hidden}.entry-cmd.dim{color:var(--faint)}.entry-status{color:var(--faint);align-items:center;gap:8px;font-size:12px;display:inline-flex}.entry-note{color:var(--muted);padding:0 16px 12px 120px;font-size:12px}.status-ok{color:var(--ok)}.status-ko{color:var(--danger)}.status-idle{color:var(--faint)}.status-run{color:var(--accent-soft)}.terminal{background:var(--terminal);border:1px solid var(--divider);font-family:var(--font-mono);color:var(--text-soft);white-space:pre-wrap;word-break:break-word;border-radius:6px;max-height:360px;margin:0 12px 12px;padding:12px 16px;font-size:12px;line-height:22px;overflow-y:auto}.terminal.flush{margin:0}.terminal .muted{color:var(--faint)}.cursor{background:var(--text-soft);vertical-align:text-bottom;width:7px;height:14px;animation:1s step-end infinite blink;display:inline-block}@keyframes blink{50%{opacity:0}}.spin{animation:1s linear infinite spin}@keyframes spin{to{transform:rotate(360deg)}}.empty-journal{text-align:center;color:var(--faint);padding:32px 0;font-size:13px}.card{background:var(--surface);border:1.5px solid var(--accent);border-radius:12px;flex-direction:column;gap:14px;margin-top:16px;padding:18px 20px 20px;display:flex}.card-head{align-items:center;gap:10px;display:flex}.card-head svg{color:var(--accent-soft);flex-shrink:0}.card-title{font-size:15px;font-weight:700}.card-meta{color:var(--faint);margin-left:auto;font-size:12px}.explanation{color:var(--text-soft);white-space:pre-wrap;font-size:13px;line-height:20px}.cmd-list{flex-direction:column;gap:6px;display:flex}.cmd-row{background:var(--surface-raised);border:1.5px solid #0000;border-radius:6px;grid-template-columns:26px 1fr auto;align-items:start;gap:0;padding:10px 12px 10px 14px;display:grid}.cmd-row:hover{background:var(--surface-current)}.cmd-row.editing{background:var(--bg);border-color:var(--accent)}.cmd-num{color:var(--faint);font-size:12px;line-height:20px}.cmd-text{font-family:var(--font-mono);white-space:pre-wrap;word-break:break-word;font-size:13px;line-height:20px}.cmd-original{font-family:var(--font-sans);color:var(--warning);margin-top:4px;font-size:11px}.cmd-actions{visibility:hidden;align-items:center;gap:4px;margin:-6px -6px -6px 12px;display:flex}.cmd-row:hover .cmd-actions,.cmd-row.editing .cmd-actions{visibility:visible}.cmd-hint{color:var(--faint);white-space:nowrap;margin-right:8px;font-size:11px}.cmd-edit{resize:none;width:100%;font-family:var(--font-mono);background:0 0;border:none;outline:none;padding:0;font-size:13px;line-height:20px}.field-label{color:var(--muted);margin-bottom:8px;font-size:12px;font-weight:500}.textarea{background:var(--bg);border:1px solid var(--border-strong);resize:vertical;border-radius:6px;outline:none;width:100%;min-height:64px;padding:10px 12px;line-height:20px}.textarea:focus,.input:focus{border-color:var(--accent)}.textarea::placeholder,.input::placeholder{color:var(--faint)}.card-foot{align-items:center;gap:12px;display:flex}.hint{color:var(--muted);flex:1;align-items:center;gap:8px;font-size:12px;display:flex}.hint svg{color:var(--faint);flex-shrink:0}.queue-note{color:var(--muted);font-size:12px}.step-row{border-radius:6px;grid-template-columns:18px 1fr auto;align-items:center;gap:12px;min-height:36px;padding:0 10px;display:grid}.step-row.current{background:var(--surface-current)}.step-row .cmd-text{white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.step-row.waiting .cmd-text{color:var(--faint)}.step-meta{color:var(--muted);font-size:12px}.tabs{background:var(--surface);border:1px solid var(--border);border-radius:8px;gap:4px;padding:3px;display:flex}.tab{min-width:100px;height:26px;color:var(--muted);border-radius:6px;justify-content:center;align-items:center;gap:8px;padding:0 12px;font-size:13px;display:inline-flex}.tab:hover:not(.active){color:var(--text-soft)}.tab.active{background:var(--surface-current);color:var(--text);font-weight:600}.tab-badge{min-width:18px;height:16px;padding:0 5px;font-size:10px}.notes-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;flex-direction:column;gap:18px;padding:22px 24px 20px;display:flex}.notes-head{align-items:flex-start;gap:12px;display:flex}.notes-head>svg{color:var(--accent-soft);flex-shrink:0;margin-top:2px}.notes-meta{color:var(--faint);margin-top:6px;font-size:12px}.notes-edit{height:36px;margin-left:auto;font-size:13px}.notes-divider{background:var(--divider);height:1px}.notes-body{color:var(--text-soft);white-space:pre-wrap;word-break:break-word;font-size:14px;line-height:24px}.notes-empty{color:var(--faint);font-size:13px}.notes-field{border-color:var(--accent);resize:none;min-height:240px;padding:14px 16px;font-size:14px;line-height:24px}.btn{white-space:nowrap;border-radius:8px;justify-content:center;align-items:center;gap:8px;height:38px;padding:0 18px;font-size:14px;font-weight:500;display:inline-flex}.btn-primary{background:var(--accent);color:#fff;font-weight:600}.btn-primary:hover:not(:disabled){background:var(--accent-hover)}.btn-secondary{background:var(--surface);border:1px solid var(--border-strong);color:var(--text-soft)}.btn-secondary:hover:not(:disabled){background:var(--surface-raised)}.btn-danger{background:var(--surface);border:1px solid var(--danger-border);color:var(--danger)}.btn-danger:hover:not(:disabled){background:#1d1416}.btn-block{justify-content:flex-start;width:100%;padding:0 14px;font-size:13px}.input{background:var(--bg);border:1px solid var(--border-strong);border-radius:8px;outline:none;width:100%;height:40px;padding:0 12px}.prompt{background:var(--surface);border:1px solid var(--border-strong);border-radius:10px;align-items:center;gap:10px;height:48px;padding:0 16px;display:flex}.prompt.disabled{opacity:.55}.prompt input{height:100%;font-family:var(--font-mono);background:0 0;border:none;outline:none;flex:1;font-size:13px}.prompt input::placeholder{font-family:var(--font-sans);color:var(--faint)}.prompt-sign{font-family:var(--font-mono);color:var(--faint)}.banner{background:var(--warning-bg);color:#f0c674;border:1px solid #4a3714;border-radius:10px;align-items:center;gap:12px;padding:14px 16px;font-size:13px;display:flex}.banner span{flex:1}.backdrop{z-index:50;background:#0009;justify-content:center;align-items:center;padding:24px;display:flex;position:fixed;inset:0}.modal{background:var(--surface);border:1px solid var(--border-strong);border-radius:14px;width:560px;max-width:100%;max-height:100%;overflow-y:auto}.modal-body{flex-direction:column;gap:18px;padding:24px 28px;display:flex}.modal-head{align-items:center;display:flex}.modal-title{flex:1;font-size:18px;font-weight:700}.modal-foot{border-top:1px solid var(--border);justify-content:flex-end;gap:12px;padding:20px 28px;display:flex}.stepper{color:var(--muted);align-items:center;gap:12px;font-size:13px;display:flex}.stepper-line{background:var(--border-strong);width:48px;height:2px}.step{align-items:center;gap:8px;display:flex}.step-dot{border:1.5px solid var(--border-strong);width:22px;height:22px;color:var(--faint);border-radius:50%;justify-content:center;align-items:center;font-size:12px;font-weight:700;display:inline-flex}.step.done .step-dot{background:var(--ok);border-color:var(--ok);color:var(--bg)}.step.active .step-dot{background:var(--accent);border-color:var(--accent);color:#fff}.step.active{color:var(--text);font-weight:600}.summary{background:var(--surface-raised);color:var(--text-soft);border-radius:8px;align-items:center;gap:12px;padding:12px 14px;font-size:13px;display:flex}.summary span{flex:1}.section-title{justify-content:space-between;align-items:center;font-size:13px;font-weight:600;display:flex}.keybox{background:var(--bg);border:1px solid var(--border);font-family:var(--font-mono);color:var(--text-soft);word-break:break-all;border-radius:8px;padding:12px 14px;font-size:12px;line-height:20px}.oneliner{background:var(--terminal);border:1px solid var(--divider);font-family:var(--font-mono);color:var(--text-soft);border-radius:8px;align-items:center;gap:12px;padding:12px 14px;font-size:12px;display:flex}.oneliner span{white-space:nowrap;text-overflow:ellipsis;flex:1;overflow:hidden}.small-btn{border:1px solid var(--border-strong);background:var(--surface);height:28px;color:var(--text-soft);border-radius:6px;align-items:center;gap:6px;padding:0 12px;font-size:12px;font-weight:500;display:inline-flex}.form-grid{grid-template-columns:1fr 110px;gap:14px;display:grid}.form-grid .wide{grid-column:1/-1}.muted-line{color:var(--muted);align-items:center;gap:8px;font-size:12px;display:flex}.result-ok{background:var(--ok-bg);color:var(--ok-text);border-radius:10px;gap:12px;padding:14px 16px;font-size:13px;line-height:20px;display:flex}.result-ko{border:1px solid var(--danger-border);color:var(--danger);background:#1d1416;border-radius:10px;gap:12px;padding:14px 16px;font-size:13px;line-height:20px;display:flex}.toast{z-index:60;border:1px solid var(--danger-border);max-width:640px;color:var(--danger);background:#1d1416;border-radius:10px;align-items:center;gap:12px;padding:12px 16px;font-size:13px;display:flex;position:fixed;bottom:24px;left:50%;transform:translate(-50%);box-shadow:0 12px 32px #00000073}.empty{text-align:center;max-width:420px;color:var(--muted);flex-direction:column;align-items:center;gap:14px;margin:auto;font-size:13px;line-height:20px;display:flex}.empty svg{color:var(--accent-soft)}.empty-title{color:var(--text);font-size:18px;font-weight:700}
|