mulmoterminal 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.
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>mulmoterminal</title>
8
+ <script type="module" crossorigin src="/assets/index-r5DjoOQU.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-Bhcnvx68.css">
10
+ </head>
11
+ <body>
12
+ <div id="app"></div>
13
+ </body>
14
+ </html>
package/package.json ADDED
@@ -0,0 +1,100 @@
1
+ {
2
+ "name": "mulmoterminal",
3
+ "version": "0.1.0",
4
+ "description": "MulmoTerminal — browser terminal + GUI panel for Claude Code. One command to start.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/receptron/mulmoterminal.git"
10
+ },
11
+ "keywords": [
12
+ "claude",
13
+ "claude-code",
14
+ "terminal",
15
+ "gui",
16
+ "ai",
17
+ "agent",
18
+ "mulmoterminal"
19
+ ],
20
+ "bin": {
21
+ "mulmoterminal": "bin/mulmoterminal.js"
22
+ },
23
+ "files": [
24
+ "bin/",
25
+ "dist/",
26
+ "server/",
27
+ "plugins/"
28
+ ],
29
+ "engines": {
30
+ "node": ">=22.9"
31
+ },
32
+ "scripts": {
33
+ "dev": "concurrently -n server,vite -c blue,green \"node --import tsx --env-file-if-exists=.env server/index.ts\" \"vite\"",
34
+ "dev:server": "node --import tsx --env-file-if-exists=.env server/index.ts",
35
+ "dev:client": "vite",
36
+ "build": "vue-tsc -b && vite build",
37
+ "typecheck": "vue-tsc -b",
38
+ "typecheck:server": "tsc -p tsconfig.server.json",
39
+ "lint": "eslint .",
40
+ "format": "prettier --write .",
41
+ "format:check": "prettier --check .",
42
+ "preview": "vite preview",
43
+ "server": "node --import tsx --env-file-if-exists=.env server/index.ts",
44
+ "test": "vitest run",
45
+ "prepack": "vue-tsc -b && vite build",
46
+ "postinstall": "node server/fix-pty-perms.js"
47
+ },
48
+ "dependencies": {
49
+ "@google/genai": "^2.8.0",
50
+ "@marp-team/marp-core": "^4.3.0",
51
+ "@modelcontextprotocol/sdk": "^1.29.0",
52
+ "@mulmochat-plugin/generate-image": "^0.4.1",
53
+ "@mulmochat-plugin/ui-image": "^0.4.0",
54
+ "@mulmoclaude/form-plugin": "^0.1.1",
55
+ "@mulmoclaude/markdown-plugin": "^0.1.4",
56
+ "@xterm/addon-attach": "^0.12.0",
57
+ "@xterm/addon-fit": "^0.11.0",
58
+ "@xterm/addon-web-links": "^0.12.0",
59
+ "@xterm/xterm": "^6.0.0",
60
+ "dompurify": "^3.4.10",
61
+ "express": "^5.2.1",
62
+ "gui-chat-protocol": "0.3.3",
63
+ "js-yaml": "^4.1.0",
64
+ "marked": "^18.0.5",
65
+ "node-pty": "^1.1.0",
66
+ "puppeteer": "^24.8.1",
67
+ "socket.io": "^4.8.3",
68
+ "socket.io-client": "^4.8.3",
69
+ "tsx": "^4.22.4",
70
+ "vue": "^3.5.34",
71
+ "ws": "^8.21.0"
72
+ },
73
+ "devDependencies": {
74
+ "@eslint/js": "^10.0.1",
75
+ "@tailwindcss/vite": "^4.3.1",
76
+ "@types/express": "^5.0.6",
77
+ "@types/node": "^25.9.3",
78
+ "@types/ws": "^8.18.1",
79
+ "@vitejs/plugin-vue": "^6.0.6",
80
+ "@vue/test-utils": "^2.4.11",
81
+ "@vue/tsconfig": "^0.9.1",
82
+ "concurrently": "^10.0.3",
83
+ "eslint": "^10.5.0",
84
+ "eslint-config-prettier": "^10.1.8",
85
+ "eslint-plugin-prettier": "^5.5.6",
86
+ "eslint-plugin-security": "^4.0.1",
87
+ "eslint-plugin-sonarjs": "^4.0.3",
88
+ "eslint-plugin-vue": "^10.9.2",
89
+ "globals": "^17.6.0",
90
+ "jsdom": "^29.1.1",
91
+ "prettier": "^3.8.4",
92
+ "tailwindcss": "^4.3.1",
93
+ "typescript": "~6.0.2",
94
+ "typescript-eslint": "^8.61.0",
95
+ "vite": "^8.0.12",
96
+ "vitest": "^4.1.8",
97
+ "vue-eslint-parser": "^10.4.1",
98
+ "vue-tsc": "^3.2.8"
99
+ }
100
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "packages": ["@mulmoclaude/markdown-plugin", "@mulmoclaude/form-plugin", "@mulmochat-plugin/generate-image"],
3
+ "local": []
4
+ }
@@ -0,0 +1,74 @@
1
+ // Image-generation backend for the @mulmochat-plugin/generate-image plugin.
2
+ //
3
+ // The package is host-agnostic: its execute() calls `context.app.generateImage(prompt)`
4
+ // and returns whatever ToolResult that yields. The server plugin registry injects
5
+ // THIS function as `context.app.generateImage` (see plugins-registry.js), so the
6
+ // generateImage tool resolves to a real Gemini call here.
7
+ //
8
+ // We return the image as a base64 data URI in `data.imageData`; ui-image's ImageView
9
+ // binds that straight into `<img src>`, so MulmoTerminal needs no image storage or
10
+ // serving route. Mirrors MulmoClaude's server/utils/gemini.ts (same model + config).
11
+ import { GoogleGenAI } from "@google/genai";
12
+
13
+ // Mirrors MulmoClaude's default. This is a PREVIEW model Google schedules for
14
+ // retirement (~mid-2026); override with GEMINI_IMAGE_MODEL to pin a stable model
15
+ // (e.g. "gemini-2.5-flash-image") without a code change.
16
+ const DEFAULT_IMAGE_MODEL = process.env.GEMINI_IMAGE_MODEL || "gemini-3.1-flash-image-preview";
17
+ const DEFAULT_IMAGE_CONFIG = {
18
+ responseModalities: ["TEXT", "IMAGE"],
19
+ imageConfig: { aspectRatio: "16:9" },
20
+ };
21
+
22
+ // The MIME type comes from the (untrusted) model response and is embedded into a
23
+ // `data:` URL, so constrain it to a safe image allowlist and default to PNG.
24
+ const ALLOWED_IMAGE_MIME = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
25
+
26
+ let client: GoogleGenAI | null = null;
27
+ function getClient() {
28
+ const apiKey = process.env.GEMINI_API_KEY;
29
+ if (!apiKey) return null;
30
+ if (!client) client = new GoogleGenAI({ apiKey });
31
+ return client;
32
+ }
33
+
34
+ // gui-chat-protocol ToolContext.app.generateImage contract: (prompt) -> ToolResult.
35
+ export async function generateImage(prompt: string) {
36
+ const ai = getClient();
37
+ if (!ai) {
38
+ return { message: "Image generation is unavailable on this server (set GEMINI_API_KEY)." };
39
+ }
40
+ let response;
41
+ try {
42
+ response = await ai.models.generateContent({
43
+ model: DEFAULT_IMAGE_MODEL,
44
+ contents: [{ text: prompt }],
45
+ config: DEFAULT_IMAGE_CONFIG,
46
+ });
47
+ } catch (e) {
48
+ return { message: `Image generation failed: ${e instanceof Error ? e.message : String(e)}` };
49
+ }
50
+
51
+ // Reduce the response parts to the first inline image + any text (mirrors
52
+ // gemini.ts#extractImageResult).
53
+ const parts = response?.candidates?.[0]?.content?.parts ?? [];
54
+ let imageData;
55
+ let mimeType = "image/png";
56
+ let text;
57
+ for (const part of parts) {
58
+ if (part.inlineData?.data) {
59
+ imageData = part.inlineData.data;
60
+ const mt = part.inlineData.mimeType;
61
+ if (mt && ALLOWED_IMAGE_MIME.has(mt)) mimeType = mt;
62
+ } else if (part.text) {
63
+ text = part.text;
64
+ }
65
+ }
66
+
67
+ if (!imageData) {
68
+ return { message: text || "Gemini returned no image (the prompt may have been filtered)." };
69
+ }
70
+ return {
71
+ message: text || `Generated an image for: ${prompt}`,
72
+ data: { imageData: `data:${mimeType};base64,${imageData}`, prompt },
73
+ };
74
+ }
@@ -0,0 +1,164 @@
1
+ // MarkdownHostApp backend for the @mulmoclaude/markdown-plugin presentDocument
2
+ // plugin (task #6 Phase 4). The package's View reaches these via
3
+ // useRuntime().dispatch({ kind }) → POST /api/plugin/presentDocument →
4
+ // execute({ app }, args) → context.app.<method> (plugins-registry.ts injects
5
+ // this into APP_CONTEXT). The package's create path also calls fillImages +
6
+ // saveNewDoc here.
7
+ //
8
+ // MulmoTerminal specifics vs MulmoClaude:
9
+ // - Docs are plain files under <workspace>/artifacts/documents/YYYY/MM/ — the
10
+ // `artifacts/documents/` prefix is what the package's isFilePath() recognises,
11
+ // so the View loads/saves/live-refreshes them.
12
+ // - Images come back as base64 data URIs from Gemini (no image store / serving
13
+ // route), inlined straight into the markdown — so fillImages needs no storage
14
+ // and PDF export needs no image-resolution step.
15
+ import fs from "fs";
16
+ import path from "path";
17
+ import { randomUUID } from "node:crypto";
18
+ import { marked } from "marked";
19
+ import { renderMarpDeck, fillImagePlaceholders } from "@mulmoclaude/markdown-plugin";
20
+ import type { MarkdownHostApp, ExportPdfOptions } from "@mulmoclaude/markdown-plugin";
21
+ import type { createPubSub } from "../pubsub.js";
22
+ import { generateImage } from "./image-gen.js";
23
+
24
+ type PubSub = ReturnType<typeof createPubSub>;
25
+
26
+ const DOCS_DIR = "artifacts/documents";
27
+
28
+ // Set once at boot (server/index.ts) — workspace = CLAUDE_CWD, pubsub for
29
+ // live-refresh forwarding to the plugin-scoped channel.
30
+ let workspace: string | null = null;
31
+ let pubsub: PubSub | null = null;
32
+
33
+ export function initMarkdownBackend(deps: { workspace: string; pubsub: PubSub | null }): void {
34
+ workspace = deps.workspace;
35
+ pubsub = deps.pubsub ?? null;
36
+ }
37
+
38
+ // Strict gate (matches the package's isFilePath + MulmoClaude's isMarkdownPath):
39
+ // only `artifacts/documents/**.md`, normalized, no traversal.
40
+ function isDocPath(rel: string): boolean {
41
+ if (!rel.startsWith(`${DOCS_DIR}/`) || !rel.endsWith(".md")) return false;
42
+ const normalized = path.posix.normalize(rel);
43
+ return normalized === rel && !normalized.includes("..");
44
+ }
45
+
46
+ function absFor(rel: string): string {
47
+ if (!workspace) throw new Error("markdown backend not initialised (missing workspace)");
48
+ return path.join(workspace, rel);
49
+ }
50
+
51
+ function sanitizePrefix(prefix: string): string {
52
+ const cleaned = String(prefix || "document")
53
+ .toLowerCase()
54
+ .replace(/[^a-z0-9]+/g, "-")
55
+ // Runs are already collapsed to a single "-" above, so trim one at each end
56
+ // (no quantifier — keeps the regex trivially linear).
57
+ .replace(/^-|-$/g, "")
58
+ .slice(0, 60);
59
+ return cleaned || "document";
60
+ }
61
+
62
+ function buildNewDocPath(prefix: string): string {
63
+ const now = new Date();
64
+ const yyyy = String(now.getFullYear());
65
+ const mm = String(now.getMonth() + 1).padStart(2, "0");
66
+ const rand = randomUUID().slice(0, 8);
67
+ return `${DOCS_DIR}/${yyyy}/${mm}/${sanitizePrefix(prefix)}-${rand}.md`;
68
+ }
69
+
70
+ function publishFileChange(rel: string): void {
71
+ // The package View subscribes via runtime.pubsub "file:<path>" →
72
+ // "plugin:markdown:file:<path>". Forward self-saves so other tabs refresh.
73
+ pubsub?.publish(`plugin:markdown:file:${rel}`, { path: rel, mtimeMs: Date.now() });
74
+ }
75
+
76
+ const MARKDOWN_PDF_CSS = `
77
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; font-size: 13px; line-height: 1.6; color: #1f2937; max-width: 800px; margin: 0 auto; padding: 32px 48px; }
78
+ h1 { font-size: 1.75rem; } h2 { font-size: 1.25rem; border-bottom: 1px solid #e5e7eb; padding-bottom: .25rem; } h3 { font-size: 1rem; }
79
+ pre { background: #f3f4f6; padding: .75rem; border-radius: .375rem; overflow-x: auto; } code { background: #f3f4f6; padding: .1rem .3rem; border-radius: .25rem; }
80
+ table { border-collapse: collapse; width: 100%; } th, td { border: 1px solid #e5e7eb; padding: .5rem .75rem; } a { color: #2563eb; } img { max-width: 100%; height: auto; }
81
+ `;
82
+
83
+ export const markdownHostApp: MarkdownHostApp = {
84
+ async loadDoc(rel) {
85
+ if (!isDocPath(rel)) throw new Error(`invalid document path: ${rel}`);
86
+ const content = await fs.promises.readFile(absFor(rel), "utf8");
87
+ return { content };
88
+ },
89
+
90
+ async saveDoc(rel, markdown) {
91
+ if (!isDocPath(rel)) throw new Error(`invalid document path: ${rel}`);
92
+ await fs.promises.writeFile(absFor(rel), markdown);
93
+ publishFileChange(rel);
94
+ return { path: rel };
95
+ },
96
+
97
+ async saveNewDoc(prefix, markdown) {
98
+ const rel = buildNewDocPath(prefix);
99
+ const abs = absFor(rel);
100
+ await fs.promises.mkdir(path.dirname(abs), { recursive: true });
101
+ await fs.promises.writeFile(abs, markdown);
102
+ return { path: rel };
103
+ },
104
+
105
+ async marpThemes() {
106
+ // No workspace Marp themes in MulmoTerminal yet — decks use Marp's built-ins.
107
+ return { themes: [] };
108
+ },
109
+
110
+ async fillImages(markdown) {
111
+ const { markdown: filled } = await fillImagePlaceholders(markdown, {
112
+ // Gemini returns a base64 data URI in result.data.imageData; inline it
113
+ // directly (no image store). null → the package leaves a text marker.
114
+ resolveImage: async (prompt) => {
115
+ const result = await generateImage(prompt);
116
+ return "data" in result && result.data ? result.data.imageData : null;
117
+ },
118
+ });
119
+ return { markdown: filled };
120
+ },
121
+
122
+ async exportPdf(options: ExportPdfOptions) {
123
+ // Lazy-load puppeteer so the server still boots when it isn't installed
124
+ // (heavy Chromium dep). Images are already data URIs in the markdown, so no
125
+ // image-resolution step is needed before printing.
126
+ let puppeteerMod: typeof import("puppeteer");
127
+ try {
128
+ puppeteerMod = await import("puppeteer");
129
+ } catch {
130
+ throw new Error("PDF export requires puppeteer (not installed on this server)");
131
+ }
132
+ const browser = await puppeteerMod.default.launch({ headless: true });
133
+ try {
134
+ const page = await browser.newPage();
135
+ let pdf: Uint8Array;
136
+ if (options.marp) {
137
+ const { html, css, slideWidth, slideHeight } = await renderMarpDeck(options.markdown, { themes: [], inlineSVG: true });
138
+ const fullHtml = `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;padding:0;background:white}${css}
139
+ div.marpit > svg > foreignObject > section img:not([data-marp-twemoji]){max-width:100%;max-height:60cqh;object-fit:contain}
140
+ </style></head><body>${html}</body></html>`;
141
+ await page.setViewport({ width: slideWidth, height: slideHeight });
142
+ await page.setContent(fullHtml, { waitUntil: "load" });
143
+ pdf = await page.pdf({
144
+ width: `${slideWidth}px`,
145
+ height: `${slideHeight}px`,
146
+ margin: { top: "0", bottom: "0", left: "0", right: "0" },
147
+ printBackground: true,
148
+ });
149
+ } else {
150
+ const body = await marked.parse(options.markdown);
151
+ const fullHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>${MARKDOWN_PDF_CSS}</style></head><body>${body}</body></html>`;
152
+ await page.setContent(fullHtml, { waitUntil: "load" });
153
+ pdf = await page.pdf({
154
+ format: options.format === "A4" ? "A4" : "Letter",
155
+ margin: { top: "16mm", bottom: "16mm", left: "16mm", right: "16mm" },
156
+ printBackground: true,
157
+ });
158
+ }
159
+ return { pdfBase64: Buffer.from(pdf).toString("base64") };
160
+ } finally {
161
+ await browser.close();
162
+ }
163
+ },
164
+ };
@@ -0,0 +1,15 @@
1
+ // Fix node-pty spawn-helper permissions (macOS npm tarball ships 644)
2
+ import { chmodSync, existsSync } from "fs";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const base = path.resolve(__dirname, "../node_modules/node-pty/prebuilds");
8
+
9
+ for (const arch of ["darwin-arm64", "darwin-x64"]) {
10
+ const helper = path.join(base, arch, "spawn-helper");
11
+ if (existsSync(helper)) {
12
+ chmodSync(helper, 0o755);
13
+ console.log(`Fixed permissions: ${helper}`);
14
+ }
15
+ }
@@ -0,0 +1,45 @@
1
+ // Host tools: built-in GUI-protocol tools whose execute lives in the main server
2
+ // (server/index.ts) rather than in a plugin module, because they need server
3
+ // internals the plugin sandbox doesn't expose — e.g. the live PTY table. The
4
+ // registry owns only their DEFINITIONS, so the MCP broker lists them and they're
5
+ // auto-allowed (allowedToolNames); index.ts mounts their dispatch route itself,
6
+ // before plugins-registry's catch-all /api/plugin/:toolName.
7
+ import type { ToolDefinition } from "gui-chat-protocol";
8
+
9
+ // Mirrors MulmoClaude's spawnBackgroundChat signature (message/role/hidden) so the
10
+ // tool is a drop-in from the model's point of view — but the implementation is
11
+ // completely different: instead of starting an SDK chat, it spawns a brand-new
12
+ // interactive Claude terminal session on the server, seeded with `message`, that
13
+ // the user can open from the sidebar. `role` and `hidden` are accepted for
14
+ // signature parity but ignored: the spawned session is always a visible terminal.
15
+ export const SPAWN_BACKGROUND_CHAT: ToolDefinition = {
16
+ type: "function",
17
+ name: "spawnBackgroundChat",
18
+ description:
19
+ "Launch a separate, parallel chat session that runs its own agent turn concurrently with this conversation, then returns immediately (fire-and-forget). Use it to do work off the critical path — e.g. pre-generate an artifact the user will need soon — without blocking the current turn. Returns the new session's chatId.",
20
+ prompt:
21
+ "Use `spawnBackgroundChat` to run work in parallel with the current conversation instead of making the user wait for it inline. " +
22
+ "The `message` must be fully self-contained — the spawned session shares NONE of this chat's context — and should state exactly what to produce and where to write it. " +
23
+ "It returns right away with a `chatId`; it does NOT wait for the spawned session to finish. The new session appears in the sidebar and the user can open it at any time.",
24
+ parameters: {
25
+ type: "object",
26
+ properties: {
27
+ message: {
28
+ type: "string",
29
+ description:
30
+ "The first user turn for the spawned session — a complete, self-contained instruction, since the worker shares none of this conversation's context.",
31
+ },
32
+ role: {
33
+ type: "string",
34
+ description: "Role id the spawned session runs in. (Accepted for compatibility; currently ignored.)",
35
+ },
36
+ hidden: {
37
+ type: "boolean",
38
+ description: "Accepted for compatibility; currently ignored — the spawned session is always visible to the user.",
39
+ },
40
+ },
41
+ required: ["message", "role", "hidden"],
42
+ },
43
+ };
44
+
45
+ export const HOST_TOOL_DEFINITIONS: ToolDefinition[] = [SPAWN_BACKGROUND_CHAT];