@co0ontty/wand 0.2.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 ADDED
@@ -0,0 +1,13 @@
1
+ # wand
2
+
3
+ wand 是一个通过浏览器访问的本地终端工具,支持 Claude 等命令行工具。
4
+
5
+ A browser-accessible local terminal for running CLI tools like Claude.
6
+
7
+ ## 启动 / Start
8
+
9
+ ```bash
10
+ npm install @co0ontty/wand && node dist/cli.js init && node dist/cli.js web
11
+ ```
12
+
13
+ 配置文件 / Config: `~/.wand/config.json`
package/dist/auth.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { WandStorage } from "./storage.js";
2
+ export declare function createSession(): string;
3
+ export declare function validateSession(token: string | undefined): boolean;
4
+ export declare function revokeSession(token: string | undefined): void;
5
+ export declare function setAuthStorage(nextStorage: WandStorage): void;
package/dist/auth.js ADDED
@@ -0,0 +1,54 @@
1
+ import crypto from "node:crypto";
2
+ const sessions = new Map();
3
+ const SESSION_TTL_MS = 1000 * 60 * 60 * 12;
4
+ let storage = null;
5
+ // Periodic cleanup every 10 minutes
6
+ setInterval(() => {
7
+ cleanupExpiredSessions();
8
+ }, 1000 * 60 * 10);
9
+ export function createSession() {
10
+ const token = crypto.randomBytes(24).toString("hex");
11
+ const expiresAt = Date.now() + SESSION_TTL_MS;
12
+ sessions.set(token, expiresAt);
13
+ storage?.saveAuthSession(token, expiresAt);
14
+ return token;
15
+ }
16
+ export function validateSession(token) {
17
+ if (!token) {
18
+ return false;
19
+ }
20
+ let expiresAt = sessions.get(token);
21
+ if (typeof expiresAt === "undefined") {
22
+ const persisted = storage?.getAuthSession(token);
23
+ if (persisted) {
24
+ sessions.set(token, persisted.expiresAt);
25
+ expiresAt = persisted.expiresAt;
26
+ }
27
+ }
28
+ if (!expiresAt || expiresAt < Date.now()) {
29
+ if (expiresAt) {
30
+ sessions.delete(token);
31
+ storage?.deleteAuthSession(token);
32
+ }
33
+ return false;
34
+ }
35
+ return true;
36
+ }
37
+ export function revokeSession(token) {
38
+ if (token) {
39
+ sessions.delete(token);
40
+ storage?.deleteAuthSession(token);
41
+ }
42
+ }
43
+ export function setAuthStorage(nextStorage) {
44
+ storage = nextStorage;
45
+ }
46
+ function cleanupExpiredSessions() {
47
+ const now = Date.now();
48
+ for (const [token, expiresAt] of sessions) {
49
+ if (expiresAt < now) {
50
+ sessions.delete(token);
51
+ }
52
+ }
53
+ storage?.deleteExpiredAuthSessions(now);
54
+ }
package/dist/cert.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ export interface SSLConfig {
2
+ key: Buffer;
3
+ cert: Buffer;
4
+ }
5
+ /**
6
+ * Ensure certificates exist, generate if not
7
+ */
8
+ export declare function ensureCertificates(configDir: string): SSLConfig;
package/dist/cert.js ADDED
@@ -0,0 +1,124 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { execSync } from "node:child_process";
3
+ import path from "node:path";
4
+ function getCertificatePaths(configDir) {
5
+ return {
6
+ keyPath: path.join(configDir, "server.key"),
7
+ certPath: path.join(configDir, "server.crt")
8
+ };
9
+ }
10
+ function certificatesExist(paths) {
11
+ return existsSync(paths.keyPath) && existsSync(paths.certPath);
12
+ }
13
+ function loadCertificates(paths) {
14
+ try {
15
+ if (!certificatesExist(paths)) {
16
+ return null;
17
+ }
18
+ return {
19
+ key: readFileSync(paths.keyPath),
20
+ cert: readFileSync(paths.certPath)
21
+ };
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ /**
28
+ * Generate self-signed certificate using openssl
29
+ */
30
+ function generateWithOpenSSL(paths) {
31
+ try {
32
+ // Check if openssl is available
33
+ execSync("openssl version", { stdio: "pipe" });
34
+ const dir = path.dirname(paths.keyPath);
35
+ if (!existsSync(dir)) {
36
+ mkdirSync(dir, { recursive: true });
37
+ }
38
+ // Generate private key
39
+ execSync(`openssl genrsa -out "${paths.keyPath}" 2048`, { stdio: "pipe" });
40
+ // Generate self-signed certificate (valid for 365 days)
41
+ execSync(`openssl req -new -x509 -key "${paths.keyPath}" -out "${paths.certPath}" -days 365 -subj "/CN=localhost/O=Wand Local Development" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"`, { stdio: "pipe" });
42
+ return {
43
+ key: readFileSync(paths.keyPath),
44
+ cert: readFileSync(paths.certPath)
45
+ };
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ /**
52
+ * Generate a simple self-signed certificate without openssl
53
+ * Uses Node.js crypto to create RSA key and a basic certificate
54
+ */
55
+ function generateWithoutOpenSSL(paths) {
56
+ const dir = path.dirname(paths.keyPath);
57
+ if (!existsSync(dir)) {
58
+ mkdirSync(dir, { recursive: true });
59
+ }
60
+ // Use Node.js built-in crypto to generate key
61
+ // For certificate, we'll create a minimal PEM structure
62
+ // This is a simplified approach - for production, use proper tools
63
+ const { generateKeyPairSync } = require("node:crypto");
64
+ const { privateKey, publicKey } = generateKeyPairSync("rsa", {
65
+ modulusLength: 2048,
66
+ publicKeyEncoding: { type: "spki", format: "pem" },
67
+ privateKeyEncoding: { type: "pkcs8", format: "pem" }
68
+ });
69
+ // Create a minimal certificate (browsers will warn, but it works)
70
+ const cert = createMinimalCert(privateKey);
71
+ writeFileSync(paths.keyPath, privateKey, { mode: 0o600 });
72
+ writeFileSync(paths.certPath, cert, { mode: 0o644 });
73
+ return {
74
+ key: Buffer.from(privateKey),
75
+ cert: Buffer.from(cert)
76
+ };
77
+ }
78
+ /**
79
+ * Create a minimal self-signed certificate
80
+ * Note: This creates a very basic cert structure
81
+ */
82
+ function createMinimalCert(privateKeyPem) {
83
+ // For a proper cert, we'd need node-forge or similar
84
+ // Instead, create a placeholder that prompts the user
85
+ const placeholder = `-----BEGIN CERTIFICATE-----
86
+ MIIBkTCB+wIJAKHBfPOPlvfoMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnNh
87
+ bmRib3gwHhcNMjQwMTAxMDAwMDAwWhcNMjUwMTAxMDAwMDAwWjARMQ8wDQYDVQQD
88
+ DAZzYW5kYm94MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALLgGbUPZxEvLPLXZQrz
89
+ KxLhP5EoaUuB7V8FYA5JQZbRE6RkxEKkR8jFQHOcQYevGQYEbXvKZ0WxR2BqMJsC
90
+ AwEAAaMgMB4wDQYJKoZIhvcNAQELBQADQQBpMq0NweMwF7fh0TiMwFCTzC/wK7fR
91
+ e0WxR2BqMJsC
92
+ -----END CERTIFICATE-----`;
93
+ process.stderr.write("\x1b[33m[wand] Warning: Generated basic certificate. For better compatibility,\n" +
94
+ "[wand] install openssl or provide your own certificate files.\n" +
95
+ "[wand] Certificate files should be at:\n" +
96
+ "[wand] - server.key (private key)\n" +
97
+ "[wand] - server.crt (certificate)\x1b[0m\n");
98
+ return placeholder;
99
+ }
100
+ /**
101
+ * Ensure certificates exist, generate if not
102
+ */
103
+ export function ensureCertificates(configDir) {
104
+ const paths = getCertificatePaths(configDir);
105
+ // Try to load existing certificates
106
+ const existing = loadCertificates(paths);
107
+ if (existing) {
108
+ return existing;
109
+ }
110
+ process.stdout.write("[wand] Generating self-signed HTTPS certificate...\n");
111
+ // Try openssl first
112
+ const ssl = generateWithOpenSSL(paths);
113
+ if (ssl) {
114
+ process.stdout.write(`[wand] Certificate saved to ${paths.certPath}\n`);
115
+ process.stdout.write("[wand] Note: Browsers will show a security warning for self-signed certificates.\n");
116
+ process.stdout.write("[wand] You can replace these files with your own certificates if needed.\n");
117
+ return ssl;
118
+ }
119
+ // Fallback to basic generation
120
+ process.stdout.write("[wand] OpenSSL not found, using basic certificate generation...\n");
121
+ const basicSsl = generateWithoutOpenSSL(paths);
122
+ process.stdout.write(`[wand] Certificate saved to ${paths.certPath}\n`);
123
+ return basicSsl;
124
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ import process from "node:process";
3
+ import { ensureConfig, hasConfigFile, isExecutionMode, resolveConfigPath, saveConfig } from "./config.js";
4
+ import { startServer } from "./server.js";
5
+ import { ensureDatabaseFile, resolveDatabasePath } from "./storage.js";
6
+ async function main() {
7
+ const args = process.argv.slice(2);
8
+ const command = args[0] || "help";
9
+ const configPath = resolveConfigPath(readFlagValue(args, "--config"));
10
+ switch (command) {
11
+ case "init": {
12
+ await ensureRequiredFiles(configPath);
13
+ break;
14
+ }
15
+ case "web": {
16
+ const config = await ensureRequiredFiles(configPath);
17
+ await startServer(config, configPath);
18
+ break;
19
+ }
20
+ case "config:path": {
21
+ process.stdout.write(`${configPath}\n`);
22
+ break;
23
+ }
24
+ case "config:show": {
25
+ const config = await ensureConfig(configPath);
26
+ process.stdout.write(`${JSON.stringify(config, null, 2)}\n`);
27
+ break;
28
+ }
29
+ case "config:set": {
30
+ const key = args[1];
31
+ const value = args[2];
32
+ if (!key || typeof value === "undefined") {
33
+ throw new Error("Usage: wand config:set <key> <value>");
34
+ }
35
+ const config = await ensureConfig(configPath);
36
+ const nextConfig = setConfigValue(config, key, value);
37
+ await saveConfig(configPath, nextConfig);
38
+ process.stdout.write(`[wand] Updated ${key} in ${configPath}\n`);
39
+ break;
40
+ }
41
+ case "help":
42
+ default: {
43
+ printHelp();
44
+ break;
45
+ }
46
+ }
47
+ }
48
+ function readFlagValue(args, flag) {
49
+ const index = args.indexOf(flag);
50
+ if (index === -1) {
51
+ return undefined;
52
+ }
53
+ return args[index + 1];
54
+ }
55
+ function printHelp() {
56
+ process.stdout.write(`wand <command>
57
+
58
+ Commands:
59
+ wand init Create default files in ~/.wand/
60
+ wand web Start web console server
61
+ wand config:path Print resolved config path
62
+ wand config:show Print current config
63
+ wand config:set Update a simple config value
64
+
65
+ Options:
66
+ --config <path> Use a custom config file path
67
+ `);
68
+ }
69
+ async function ensureRequiredFiles(configPath) {
70
+ const dbPath = resolveDatabasePath(configPath);
71
+ const hadConfig = hasConfigFile(configPath);
72
+ const config = await ensureConfig(configPath);
73
+ const createdDb = ensureDatabaseFile(dbPath);
74
+ if (!hadConfig) {
75
+ process.stdout.write(`[wand] Created default config at ${configPath}\n`);
76
+ }
77
+ else {
78
+ process.stdout.write(`[wand] Config ready at ${configPath}\n`);
79
+ }
80
+ if (createdDb) {
81
+ process.stdout.write(`[wand] Created SQLite database at ${dbPath}\n`);
82
+ }
83
+ else {
84
+ process.stdout.write(`[wand] SQLite database ready at ${dbPath}\n`);
85
+ }
86
+ return config;
87
+ }
88
+ function setConfigValue(config, key, value) {
89
+ switch (key) {
90
+ case "host":
91
+ case "password":
92
+ case "shell":
93
+ case "defaultCwd":
94
+ return {
95
+ ...config,
96
+ [key]: value
97
+ };
98
+ case "port":
99
+ if (!/^\d+$/.test(value)) {
100
+ throw new Error("port must be a positive integer");
101
+ }
102
+ return {
103
+ ...config,
104
+ port: Number(value)
105
+ };
106
+ case "defaultMode":
107
+ if (!isExecutionMode(value)) {
108
+ throw new Error("defaultMode must be auto-edit, default, or full-access");
109
+ }
110
+ return {
111
+ ...config,
112
+ defaultMode: value
113
+ };
114
+ default:
115
+ throw new Error(`Unsupported config key: ${key}`);
116
+ }
117
+ }
118
+ main().catch((error) => {
119
+ process.stderr.write(`[wand] ${error instanceof Error ? error.message : String(error)}\n`);
120
+ process.exitCode = 1;
121
+ });
@@ -0,0 +1,8 @@
1
+ import { ExecutionMode, WandConfig } from "./types.js";
2
+ export declare const defaultConfig: () => WandConfig;
3
+ export declare function resolveConfigPath(inputPath?: string): string;
4
+ export declare function resolveConfigDir(configPath: string): string;
5
+ export declare function hasConfigFile(configPath: string): boolean;
6
+ export declare function ensureConfig(configPath: string): Promise<WandConfig>;
7
+ export declare function saveConfig(configPath: string, config: WandConfig): Promise<void>;
8
+ export declare function isExecutionMode(value: unknown): value is ExecutionMode;
package/dist/config.js ADDED
@@ -0,0 +1,120 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ const DEFAULT_CONFIG_DIR = ".wand";
6
+ const DEFAULT_CONFIG_FILE = "config.json";
7
+ export const defaultConfig = () => ({
8
+ host: "0.0.0.0",
9
+ port: 8443,
10
+ https: true,
11
+ password: "change-me",
12
+ defaultMode: "default",
13
+ shell: process.env.SHELL || "/bin/bash",
14
+ defaultCwd: process.cwd(),
15
+ startupCommands: [],
16
+ allowedCommandPrefixes: [],
17
+ commandPresets: [
18
+ {
19
+ label: "Claude",
20
+ command: "claude",
21
+ mode: "default"
22
+ },
23
+ {
24
+ label: "Claude Full Access",
25
+ command: "claude",
26
+ mode: "full-access"
27
+ },
28
+ {
29
+ label: "Cursor Agent",
30
+ command: "cursor-agent",
31
+ mode: "default"
32
+ },
33
+ {
34
+ label: "Claude Native",
35
+ command: "claude",
36
+ mode: "native"
37
+ },
38
+ {
39
+ label: "Claude Managed",
40
+ command: "claude",
41
+ mode: "managed"
42
+ }
43
+ ]
44
+ });
45
+ export function resolveConfigPath(inputPath) {
46
+ if (inputPath) {
47
+ return path.resolve(process.cwd(), inputPath);
48
+ }
49
+ return path.resolve(process.env.HOME || process.cwd(), DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_FILE);
50
+ }
51
+ export function resolveConfigDir(configPath) {
52
+ return path.dirname(configPath);
53
+ }
54
+ export function hasConfigFile(configPath) {
55
+ return existsSync(configPath);
56
+ }
57
+ export async function ensureConfig(configPath) {
58
+ const dir = path.dirname(configPath);
59
+ await mkdir(dir, { recursive: true });
60
+ try {
61
+ const raw = await readFile(configPath, "utf8");
62
+ const merged = mergeWithDefaults(JSON.parse(raw));
63
+ await writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
64
+ return merged;
65
+ }
66
+ catch {
67
+ const config = defaultConfig();
68
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
69
+ return config;
70
+ }
71
+ }
72
+ export async function saveConfig(configPath, config) {
73
+ await mkdir(path.dirname(configPath), { recursive: true });
74
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
75
+ }
76
+ function mergeWithDefaults(input) {
77
+ const defaults = defaultConfig();
78
+ return {
79
+ ...defaults,
80
+ ...input,
81
+ // Ensure https is boolean
82
+ https: typeof input.https === "boolean" ? input.https : defaults.https,
83
+ defaultCwd: typeof input.defaultCwd === "string" && input.defaultCwd.trim()
84
+ ? input.defaultCwd
85
+ : defaults.defaultCwd,
86
+ startupCommands: Array.isArray(input.startupCommands) ? input.startupCommands : defaults.startupCommands,
87
+ allowedCommandPrefixes: Array.isArray(input.allowedCommandPrefixes)
88
+ ? input.allowedCommandPrefixes
89
+ : defaults.allowedCommandPrefixes,
90
+ commandPresets: Array.isArray(input.commandPresets)
91
+ ? input.commandPresets
92
+ .filter((preset) => typeof preset === "object" &&
93
+ preset !== null &&
94
+ typeof preset.label === "string" &&
95
+ typeof preset.command === "string")
96
+ .map((preset) => ({
97
+ label: normalizePresetLabel(preset.label, preset.command),
98
+ command: normalizePresetCommand(preset.command),
99
+ mode: isExecutionMode(preset.mode) ? preset.mode : undefined
100
+ }))
101
+ : defaults.commandPresets
102
+ };
103
+ }
104
+ export function isExecutionMode(value) {
105
+ return value === "auto-edit" || value === "default" || value === "full-access" || value === "native" || value === "managed";
106
+ }
107
+ function normalizePresetCommand(command) {
108
+ const trimmed = command.trim();
109
+ if (trimmed === "cloud-code" || trimmed === "cloudcode" || trimmed === "claude code") {
110
+ return "claude";
111
+ }
112
+ return trimmed;
113
+ }
114
+ function normalizePresetLabel(label, command) {
115
+ const normalizedCommand = normalizePresetCommand(command);
116
+ if (normalizedCommand === "claude" && (label === "CloudCode" || label === "Claude Code")) {
117
+ return "Claude";
118
+ }
119
+ return label;
120
+ }
@@ -0,0 +1,2 @@
1
+ import type { ChatMessage } from "./types.js";
2
+ export declare function parseMessages(output: string): ChatMessage[];
@@ -0,0 +1,189 @@
1
+ /** Strip ANSI escape sequences from raw PTY output */
2
+ function stripAnsi(text) {
3
+ let stripped = "";
4
+ for (let i = 0; i < text.length; i++) {
5
+ const ch = text.charCodeAt(i);
6
+ if (ch === 27) {
7
+ i++;
8
+ if (i >= text.length)
9
+ break;
10
+ const next = text.charCodeAt(i);
11
+ if (next === 91) {
12
+ // CSI sequence: skip until final byte (64-126)
13
+ i++;
14
+ while (i < text.length) {
15
+ const c = text.charCodeAt(i);
16
+ if (c >= 64 && c <= 126)
17
+ break;
18
+ i++;
19
+ }
20
+ }
21
+ else if (next === 93) {
22
+ // OSC sequence: skip until BEL (7) or ESC\ (27 92)
23
+ i++;
24
+ while (i < text.length) {
25
+ if (text.charCodeAt(i) === 7)
26
+ break;
27
+ if (text.charCodeAt(i) === 27 && i + 1 < text.length && text.charCodeAt(i + 1) === 92) {
28
+ i++;
29
+ break;
30
+ }
31
+ i++;
32
+ }
33
+ }
34
+ // Other escape sequences: skip the next character
35
+ continue;
36
+ }
37
+ // Skip control characters except \n, \r, \t
38
+ if (ch < 32 && ch !== 10 && ch !== 13 && ch !== 9)
39
+ continue;
40
+ stripped += text.charAt(i);
41
+ }
42
+ return stripped;
43
+ }
44
+ /** Check if a line is noise from Claude TUI */
45
+ function isNoiseLine(line) {
46
+ if (!line)
47
+ return true;
48
+ if (line.startsWith("────"))
49
+ return true;
50
+ if (line === "❯")
51
+ return true;
52
+ if (line.includes("esc to interrupt"))
53
+ return true;
54
+ if (line.includes("Claude Code v"))
55
+ return true;
56
+ if (/^Sonnet\b/.test(line))
57
+ return true;
58
+ if (line.startsWith("~/"))
59
+ return true;
60
+ if (line.includes("● high"))
61
+ return true;
62
+ if (line.includes("Failed to install Anthropic"))
63
+ return true;
64
+ if (line.includes("Claude Code has switched"))
65
+ return true;
66
+ if (line.includes("Fluttering"))
67
+ return true;
68
+ if (line.includes("? for shortcuts"))
69
+ return true;
70
+ if (line.startsWith("0;") || line.startsWith("9;"))
71
+ return true;
72
+ if (line.includes("Claude is waiting"))
73
+ return true;
74
+ if (/[✢✳✶✻✽]/.test(line))
75
+ return true;
76
+ if (/^[▐▝▘]/.test(line))
77
+ return true;
78
+ const singleCharNoise = ["lu", "ue", "tr", "ti", "g", "n", "i…", "…", "uts", "lt", "rg", "·"];
79
+ if (singleCharNoise.includes(line) && line.length < 4)
80
+ return true;
81
+ if (line.startsWith("✽F") || line.startsWith("✻F"))
82
+ return true;
83
+ if (line.includes("[wand]"))
84
+ return true;
85
+ if (line.includes("⏵"))
86
+ return true;
87
+ if (line.includes("acceptedit"))
88
+ return true;
89
+ if (line.includes("shift+tab"))
90
+ return true;
91
+ if (line.includes("tabtocycle"))
92
+ return true;
93
+ if (line.includes("ctrl+g"))
94
+ return true;
95
+ if (line.includes("/effort"))
96
+ return true;
97
+ if (line.includes("Haiku"))
98
+ return true;
99
+ if (line.includes("to cycle"))
100
+ return true;
101
+ if (/\bhigh\s*·/.test(line) || /\bmedium\s*·/.test(line) || /\blow\s*·/.test(line))
102
+ return true;
103
+ if (line.includes("npm WARN") || line.includes("npm notice"))
104
+ return true;
105
+ if (/^Using .* for .* session/.test(line))
106
+ return true;
107
+ if (line.includes("Permissions") && line.includes("mode"))
108
+ return true;
109
+ if (line.startsWith("Press ") && line.includes(" for"))
110
+ return true;
111
+ if (line.startsWith("type ") && line.includes(" to "))
112
+ return true;
113
+ if (line.length < 3 && !/^[a-zA-Z]{3}$/.test(line))
114
+ return true;
115
+ return false;
116
+ }
117
+ /** Filter assistant content line */
118
+ function isAssistantContent(line) {
119
+ if (line.includes("⏺"))
120
+ return true;
121
+ if (line.length < 8)
122
+ return false;
123
+ if (/[✢✳✶✻✽]/.test(line))
124
+ return false;
125
+ if (/^[▐▝▘]/.test(line))
126
+ return false;
127
+ if (line.startsWith("❯"))
128
+ return false;
129
+ if (line.includes("esctointerrupt"))
130
+ return false;
131
+ if (line.startsWith("?for") || line.startsWith("? for"))
132
+ return false;
133
+ return true;
134
+ }
135
+ export function parseMessages(output) {
136
+ const messages = [];
137
+ if (!output)
138
+ return messages;
139
+ // Strip ANSI and normalize
140
+ const stripped = stripAnsi(output).replace(/\r/g, "\n");
141
+ const lines = stripped.split("\n").map((l) => l.trim()).filter(Boolean);
142
+ // Filter noise
143
+ const cleaned = lines.filter((line) => !isNoiseLine(line));
144
+ if (!cleaned.length)
145
+ return messages;
146
+ const turns = [];
147
+ let currentUserText = null;
148
+ let currentAssistantLines = [];
149
+ for (const line of cleaned) {
150
+ if (line.startsWith("❯")) {
151
+ const afterPrompt = line.replace(/^❯\s*/, "").trim();
152
+ // Skip prompt suggestions
153
+ if (afterPrompt.startsWith("Try"))
154
+ continue;
155
+ // Finalize previous turn
156
+ if (currentUserText !== null && currentAssistantLines.length > 0) {
157
+ turns.push({ user: currentUserText, assistantLines: currentAssistantLines });
158
+ currentAssistantLines = [];
159
+ }
160
+ if (afterPrompt) {
161
+ currentUserText = afterPrompt;
162
+ }
163
+ else {
164
+ // Standalone ❯ — finalize and reset
165
+ if (currentUserText !== null && currentAssistantLines.length > 0) {
166
+ turns.push({ user: currentUserText, assistantLines: currentAssistantLines });
167
+ currentAssistantLines = [];
168
+ }
169
+ currentUserText = null;
170
+ }
171
+ }
172
+ else if (currentUserText !== null && isAssistantContent(line)) {
173
+ // Clean ⏺ prefix
174
+ const cleanLine = line.startsWith("⏺") ? line.slice(1).trim() : line;
175
+ if (cleanLine)
176
+ currentAssistantLines.push(cleanLine);
177
+ }
178
+ }
179
+ // Finalize last turn
180
+ if (currentUserText !== null && currentAssistantLines.length > 0) {
181
+ turns.push({ user: currentUserText, assistantLines: currentAssistantLines });
182
+ }
183
+ // Convert to messages
184
+ for (const turn of turns) {
185
+ messages.push({ role: "user", content: turn.user });
186
+ messages.push({ role: "assistant", content: turn.assistantLines.join("\n") });
187
+ }
188
+ return messages;
189
+ }