@co0ontty/wand 0.3.0 → 0.4.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 +1 -1
- package/dist/avatar.d.ts +14 -0
- package/dist/avatar.js +110 -0
- package/dist/claude-pty-bridge.d.ts +0 -2
- package/dist/claude-pty-bridge.js +63 -93
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +10 -2
- package/dist/config.js +6 -2
- package/dist/message-parser.js +9 -89
- package/dist/middleware/path-safety.d.ts +6 -0
- package/dist/middleware/path-safety.js +19 -0
- package/dist/middleware/rate-limit.d.ts +8 -0
- package/dist/middleware/rate-limit.js +37 -0
- package/dist/process-manager.d.ts +52 -4
- package/dist/process-manager.js +1025 -125
- package/dist/pty-text-utils.d.ts +13 -0
- package/dist/pty-text-utils.js +84 -0
- package/dist/pwa.d.ts +5 -0
- package/dist/pwa.js +118 -0
- package/dist/server.js +346 -559
- package/dist/session-lifecycle.js +17 -12
- package/dist/session-logger.d.ts +13 -3
- package/dist/session-logger.js +56 -5
- package/dist/storage.d.ts +9 -0
- package/dist/storage.js +62 -7
- package/dist/types.d.ts +8 -2
- package/dist/web-ui/content/icon-192.png +0 -0
- package/dist/web-ui/content/icon-512.png +0 -0
- package/dist/web-ui/content/scripts.js +1571 -302
- package/dist/web-ui/content/styles.css +882 -669
- package/dist/web-ui/index.js +2 -2
- package/dist/ws-broadcast.d.ts +27 -0
- package/dist/ws-broadcast.js +160 -0
- package/package.json +1 -1
package/README.md
CHANGED
package/dist/avatar.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Random avatar (identicon) generation for PWA icons.
|
|
3
|
+
* Each installation gets a unique GitHub-style symmetric pattern.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Ensure a random seed exists for this installation.
|
|
7
|
+
* Reads existing seed from config dir, or generates and saves a new one.
|
|
8
|
+
*/
|
|
9
|
+
export declare function ensureAvatarSeed(configDir: string): Promise<string>;
|
|
10
|
+
/**
|
|
11
|
+
* Generate an SVG identicon from a seed string.
|
|
12
|
+
* Uses a 5x5 symmetric grid (mirrored to 9 columns), GitHub-style.
|
|
13
|
+
*/
|
|
14
|
+
export declare function getAvatarSvg(seed: string, size: 192 | 512): string;
|
package/dist/avatar.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Random avatar (identicon) generation for PWA icons.
|
|
3
|
+
* Each installation gets a unique GitHub-style symmetric pattern.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { mkdir as mkdirAsync, readFile as readFileAsync, writeFile as writeFileAsync } from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
const SEED_FILE = "avatar-seed.txt";
|
|
9
|
+
/**
|
|
10
|
+
* Ensure a random seed exists for this installation.
|
|
11
|
+
* Reads existing seed from config dir, or generates and saves a new one.
|
|
12
|
+
*/
|
|
13
|
+
export async function ensureAvatarSeed(configDir) {
|
|
14
|
+
const seedPath = path.join(configDir, SEED_FILE);
|
|
15
|
+
try {
|
|
16
|
+
if (existsSync(seedPath)) {
|
|
17
|
+
const seed = (await readFileAsync(seedPath, "utf8")).trim();
|
|
18
|
+
if (seed.length > 0)
|
|
19
|
+
return seed;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Fall through to generate
|
|
24
|
+
}
|
|
25
|
+
const seed = generateRandomSeed();
|
|
26
|
+
await mkdirAsync(configDir, { recursive: true });
|
|
27
|
+
await writeFileAsync(seedPath, seed, "utf8");
|
|
28
|
+
return seed;
|
|
29
|
+
}
|
|
30
|
+
function generateRandomSeed() {
|
|
31
|
+
const chars = "0123456789abcdef";
|
|
32
|
+
let seed = "";
|
|
33
|
+
for (let i = 0; i < 32; i++) {
|
|
34
|
+
seed += chars[Math.floor(Math.random() * chars.length)];
|
|
35
|
+
}
|
|
36
|
+
return seed;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Generate an SVG identicon from a seed string.
|
|
40
|
+
* Uses a 5x5 symmetric grid (mirrored to 9 columns), GitHub-style.
|
|
41
|
+
*/
|
|
42
|
+
export function getAvatarSvg(seed, size) {
|
|
43
|
+
const cellSize = Math.round(size * 0.08);
|
|
44
|
+
const gap = Math.max(1, Math.round(size * 0.01));
|
|
45
|
+
const gridSize = 5;
|
|
46
|
+
const totalWidth = gridSize * cellSize + (gridSize - 1) * gap;
|
|
47
|
+
const svgSize = size;
|
|
48
|
+
// Derive color from seed
|
|
49
|
+
const h = Math.abs(hashString(seed + "h")) % 360;
|
|
50
|
+
const s = 50 + (Math.abs(hashString(seed + "s")) % 40);
|
|
51
|
+
const l = 45 + (Math.abs(hashString(seed + "l")) % 20);
|
|
52
|
+
const color = `hsl(${h},${s}%,${l}%)`;
|
|
53
|
+
// Derive fill states from seed (5 chars for 25 cells, each char's LSB)
|
|
54
|
+
const seedChars = seed.slice(0, 25);
|
|
55
|
+
const cells = [];
|
|
56
|
+
for (let i = 0; i < 25; i++) {
|
|
57
|
+
const char = seedChars[i] || "0";
|
|
58
|
+
cells.push(parseInt(char, 16) % 2 === 0);
|
|
59
|
+
}
|
|
60
|
+
const rects = [];
|
|
61
|
+
const cx = svgSize / 2;
|
|
62
|
+
const cy = svgSize / 2;
|
|
63
|
+
const radius = svgSize * 0.22;
|
|
64
|
+
// Outer background circle
|
|
65
|
+
rects.push(`<circle cx="${cx}" cy="${cy}" r="${radius}" fill="${color}"/>`);
|
|
66
|
+
// Symmetric grid: columns 0-4, mirror to 8-4
|
|
67
|
+
for (let row = 0; row < gridSize; row++) {
|
|
68
|
+
for (let col = 0; col < gridSize; col++) {
|
|
69
|
+
const mirrorCol = gridSize - 1 - col;
|
|
70
|
+
const idx = row * gridSize + col;
|
|
71
|
+
// Only draw for left half + middle column (right half mirrors)
|
|
72
|
+
if (col > mirrorCol)
|
|
73
|
+
continue;
|
|
74
|
+
if (!cells[idx])
|
|
75
|
+
continue;
|
|
76
|
+
// Two mirrored rectangles
|
|
77
|
+
const x1 = col * (cellSize + gap);
|
|
78
|
+
const x2 = mirrorCol * (cellSize + gap);
|
|
79
|
+
const y = row * (cellSize + gap);
|
|
80
|
+
const offsetX = (svgSize - totalWidth) / 2;
|
|
81
|
+
const offsetY = (svgSize - totalWidth) / 2;
|
|
82
|
+
// Darken color for filled cells
|
|
83
|
+
const cellColor = `hsl(${h},${s}%,${l - 15}%)`;
|
|
84
|
+
const rx = Math.round(cellSize * 0.15);
|
|
85
|
+
if (col === mirrorCol) {
|
|
86
|
+
// Middle column — single centered rect
|
|
87
|
+
const mx = offsetX + col * (cellSize + gap);
|
|
88
|
+
const my = offsetY + y;
|
|
89
|
+
rects.push(`<rect x="${mx}" y="${my}" width="${cellSize}" height="${cellSize}" rx="${rx}" fill="${cellColor}"/>`);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const mx1 = offsetX + x1;
|
|
93
|
+
const mx2 = offsetX + x2;
|
|
94
|
+
const my = offsetY + y;
|
|
95
|
+
rects.push(`<rect x="${mx1}" y="${my}" width="${cellSize}" height="${cellSize}" rx="${rx}" fill="${cellColor}"/>`);
|
|
96
|
+
rects.push(`<rect x="${mx2}" y="${my}" width="${cellSize}" height="${cellSize}" rx="${rx}" fill="${cellColor}"/>`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgSize} ${svgSize}" width="${svgSize}" height="${svgSize}">
|
|
101
|
+
${rects.join("\n ")}
|
|
102
|
+
</svg>`;
|
|
103
|
+
}
|
|
104
|
+
function hashString(s) {
|
|
105
|
+
let h = 5381;
|
|
106
|
+
for (let i = 0; i < s.length; i++) {
|
|
107
|
+
h = ((h << 5) + h) ^ s.charCodeAt(i);
|
|
108
|
+
}
|
|
109
|
+
return h;
|
|
110
|
+
}
|
|
@@ -126,14 +126,12 @@ export declare class ClaudePtyBridge extends EventEmitter {
|
|
|
126
126
|
private detectCompletion;
|
|
127
127
|
private updateAssistantContent;
|
|
128
128
|
private finalizeResponse;
|
|
129
|
-
private stripAnsi;
|
|
130
129
|
/**
|
|
131
130
|
* Find the end index of the echoed user input in the PTY buffer.
|
|
132
131
|
* The echo may contain ANSI codes between characters.
|
|
133
132
|
* Returns the index after the last character of the echo.
|
|
134
133
|
*/
|
|
135
134
|
private findEchoEndIndex;
|
|
136
|
-
private isStatusLine;
|
|
137
135
|
private cleanForChat;
|
|
138
136
|
}
|
|
139
137
|
export {};
|
|
@@ -7,46 +7,18 @@
|
|
|
7
7
|
* 2. Structured messages for chat view (parsed)
|
|
8
8
|
*/
|
|
9
9
|
import { EventEmitter } from "node:events";
|
|
10
|
+
import { stripAnsi, isNoiseLine, appendWindow, normalizePromptText } from "./pty-text-utils.js";
|
|
10
11
|
// ── Constants ──
|
|
11
12
|
const OUTPUT_MAX_SIZE = 120000;
|
|
12
|
-
const SESSION_ID_WINDOW_SIZE =
|
|
13
|
+
const SESSION_ID_WINDOW_SIZE = 16384;
|
|
13
14
|
const PERMISSION_WINDOW_SIZE = 800;
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
/\((?:y|yes)\s*\/\s*(?:n|no)\)/i,
|
|
20
|
-
/\((?:y|yes)\s*\/\s*(?:n|no)\s*\/\s*always\)/i,
|
|
21
|
-
/\bcontinue\?\s*(?:\((?:y|yes)\s*\/\s*(?:n|no)\))?/i,
|
|
22
|
-
/\bare you sure\??/i,
|
|
23
|
-
/\bdo you want to continue\??/i,
|
|
24
|
-
/\bdo you want to (?:create|write|delete|modify|execute)/i,
|
|
25
|
-
/\bconfirm(?:\s+execution|\s+changes|\s+action)?\??/i,
|
|
26
|
-
/\bproceed\??/i,
|
|
27
|
-
/\benter to confirm\b/i,
|
|
28
|
-
/\bwould you like to\b/i,
|
|
29
|
-
/\bshall i\b/i,
|
|
30
|
-
/\bcan i\b/i,
|
|
31
|
-
/\bpermission\b/i,
|
|
32
|
-
/\bgrant\b.*\bpermission\b/i
|
|
15
|
+
const UUID_PATTERN = "([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})";
|
|
16
|
+
const CLAUDE_SESSION_ID_PATTERNS = [
|
|
17
|
+
new RegExp(`"session_id"\\s*:\\s*"${UUID_PATTERN}"`, "i"),
|
|
18
|
+
new RegExp(`(?:^|\\s)--resume\\s+${UUID_PATTERN}(?:\\s|$)`, "i"),
|
|
19
|
+
new RegExp(`(?:claude\\s+session\\s+id|session\\s+id)\\s*[:#]?\\s*${UUID_PATTERN}`, "i")
|
|
33
20
|
];
|
|
34
21
|
// ── Helper Functions ──
|
|
35
|
-
/** Append text to a windowed buffer, trimming from start if over max size. */
|
|
36
|
-
function appendWindow(buffer, chunk, maxSize) {
|
|
37
|
-
const next = buffer + chunk;
|
|
38
|
-
return next.length > maxSize ? next.slice(-maxSize) : next;
|
|
39
|
-
}
|
|
40
|
-
/** Normalize prompt text for permission detection */
|
|
41
|
-
function normalizePromptText(value) {
|
|
42
|
-
return value
|
|
43
|
-
.replace(/\u001b\[(\d+)C/g, (_match, count) => " ".repeat(Number(count) || 1))
|
|
44
|
-
.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
45
|
-
.replace(/\r/g, "\n")
|
|
46
|
-
.replace(/[ \t]+/g, " ")
|
|
47
|
-
.replace(/\n+/g, "\n")
|
|
48
|
-
.trim();
|
|
49
|
-
}
|
|
50
22
|
/** Normalize PTY output (fix line endings) */
|
|
51
23
|
function normalizePtyOutput(value) {
|
|
52
24
|
return value.replace(/\r\r\n/g, "\r\n");
|
|
@@ -273,6 +245,7 @@ export class ClaudePtyBridge extends EventEmitter {
|
|
|
273
245
|
}
|
|
274
246
|
// Clear state
|
|
275
247
|
this.permissionState.isBlocked = false;
|
|
248
|
+
this.permissionState.window = "";
|
|
276
249
|
this.permissionState.lastPrompt = null;
|
|
277
250
|
this.permissionState.lastScope = null;
|
|
278
251
|
this.permissionState.lastTarget = null;
|
|
@@ -305,6 +278,7 @@ export class ClaudePtyBridge extends EventEmitter {
|
|
|
305
278
|
*/
|
|
306
279
|
clearPermissionBlocked() {
|
|
307
280
|
this.permissionState.isBlocked = false;
|
|
281
|
+
this.permissionState.window = "";
|
|
308
282
|
this.permissionState.lastPrompt = null;
|
|
309
283
|
this.permissionState.lastScope = null;
|
|
310
284
|
this.permissionState.lastTarget = null;
|
|
@@ -324,8 +298,8 @@ export class ClaudePtyBridge extends EventEmitter {
|
|
|
324
298
|
// ANSI escape sequences (arrow keys, etc.)
|
|
325
299
|
if (trimmed.startsWith("\x1b"))
|
|
326
300
|
return false;
|
|
327
|
-
// Single "y" or "n" — likely auto-confirm response
|
|
328
|
-
if (/^[yn]$/i.test(trimmed))
|
|
301
|
+
// Single "y" or "n" — likely auto-confirm response (Claude only)
|
|
302
|
+
if (this.isClaudeCommand && /^[yn]$/i.test(trimmed))
|
|
329
303
|
return false;
|
|
330
304
|
// Just Enter/CR
|
|
331
305
|
if (trimmed === "\r" || trimmed === "\n")
|
|
@@ -336,7 +310,9 @@ export class ClaudePtyBridge extends EventEmitter {
|
|
|
336
310
|
if (this.claudeSessionId)
|
|
337
311
|
return;
|
|
338
312
|
this.sessionIdWindow = appendWindow(this.sessionIdWindow, chunk, SESSION_ID_WINDOW_SIZE);
|
|
339
|
-
const match =
|
|
313
|
+
const match = CLAUDE_SESSION_ID_PATTERNS
|
|
314
|
+
.map((pattern) => pattern.exec(this.sessionIdWindow))
|
|
315
|
+
.find((result) => Boolean(result?.[1]));
|
|
340
316
|
if (match?.[1]) {
|
|
341
317
|
this.claudeSessionId = match[1];
|
|
342
318
|
this.emitEvent({
|
|
@@ -353,8 +329,51 @@ export class ClaudePtyBridge extends EventEmitter {
|
|
|
353
329
|
this.permissionState.window = appendWindow(this.permissionState.window, chunk, PERMISSION_WINDOW_SIZE);
|
|
354
330
|
const normalized = normalizePromptText(this.permissionState.window);
|
|
355
331
|
const blocked = this.isPermissionPromptDetected(normalized);
|
|
356
|
-
if
|
|
332
|
+
// If state hasn't changed, check if a new distinct prompt appeared while already blocked
|
|
333
|
+
if (this.permissionState.isBlocked === blocked) {
|
|
334
|
+
if (blocked) {
|
|
335
|
+
const prompt = this.extractPromptText(normalized);
|
|
336
|
+
if (prompt !== this.permissionState.lastPrompt) {
|
|
337
|
+
// New permission prompt while already blocked — update and re-process
|
|
338
|
+
const target = this.extractPermissionTarget(normalized);
|
|
339
|
+
const scope = this.inferScope(normalized, target);
|
|
340
|
+
this.permissionState.lastPrompt = prompt;
|
|
341
|
+
this.permissionState.lastScope = scope;
|
|
342
|
+
this.permissionState.lastTarget = target ?? null;
|
|
343
|
+
const shouldAutoApprove = this.autoApprove || this.shouldAutoApprove(scope, target);
|
|
344
|
+
if (shouldAutoApprove) {
|
|
345
|
+
const now = Date.now();
|
|
346
|
+
if (now - this.permissionState.lastAutoConfirmAt < 500)
|
|
347
|
+
return;
|
|
348
|
+
this.permissionState.lastAutoConfirmAt = now;
|
|
349
|
+
process.stderr.write(`[wand] Auto-confirming permission for ${scope}${target ? `: ${target}` : ""}\n`);
|
|
350
|
+
if (this.ptyWrite) {
|
|
351
|
+
this.ptyWrite("\r");
|
|
352
|
+
}
|
|
353
|
+
this.permissionState.isBlocked = false;
|
|
354
|
+
this.permissionState.window = "";
|
|
355
|
+
this.permissionState.lastPrompt = null;
|
|
356
|
+
this.permissionState.lastScope = null;
|
|
357
|
+
this.permissionState.lastTarget = null;
|
|
358
|
+
this.emitEvent({
|
|
359
|
+
type: "permission.resolved",
|
|
360
|
+
sessionId: this.sessionId,
|
|
361
|
+
timestamp: Date.now(),
|
|
362
|
+
data: { resolution: "approve_once", autoApproved: true },
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
this.emitEvent({
|
|
367
|
+
type: "permission.prompt",
|
|
368
|
+
sessionId: this.sessionId,
|
|
369
|
+
timestamp: Date.now(),
|
|
370
|
+
data: { prompt, scope, target },
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
357
375
|
return;
|
|
376
|
+
}
|
|
358
377
|
this.permissionState.isBlocked = blocked;
|
|
359
378
|
if (blocked) {
|
|
360
379
|
const prompt = this.extractPromptText(normalized);
|
|
@@ -378,6 +397,7 @@ export class ClaudePtyBridge extends EventEmitter {
|
|
|
378
397
|
}
|
|
379
398
|
// Clear blocked state immediately
|
|
380
399
|
this.permissionState.isBlocked = false;
|
|
400
|
+
this.permissionState.window = "";
|
|
381
401
|
this.permissionState.lastPrompt = null;
|
|
382
402
|
this.permissionState.lastScope = null;
|
|
383
403
|
this.permissionState.lastTarget = null;
|
|
@@ -414,7 +434,6 @@ export class ClaudePtyBridge extends EventEmitter {
|
|
|
414
434
|
return (/\bdo you want to\b/i.test(normalized) ||
|
|
415
435
|
/\bgrant\b.*\bpermission\b/i.test(normalized) ||
|
|
416
436
|
/\bhaven't granted\b/i.test(normalized) ||
|
|
417
|
-
/\bpermission\b/i.test(normalized) ||
|
|
418
437
|
/\benter to confirm\b/i.test(normalized));
|
|
419
438
|
}
|
|
420
439
|
extractPromptText(normalized) {
|
|
@@ -443,7 +462,7 @@ export class ClaudePtyBridge extends EventEmitter {
|
|
|
443
462
|
return "unknown";
|
|
444
463
|
}
|
|
445
464
|
parseChatResponse() {
|
|
446
|
-
const clean =
|
|
465
|
+
const clean = stripAnsi(this.chatState.buffer);
|
|
447
466
|
if (!this.chatState.echoSkipped) {
|
|
448
467
|
const echoEndIndex = this.findEchoEndIndex(clean, this.chatState.lastUserInput);
|
|
449
468
|
if (echoEndIndex <= 0) {
|
|
@@ -467,7 +486,7 @@ export class ClaudePtyBridge extends EventEmitter {
|
|
|
467
486
|
if (!trimmed) {
|
|
468
487
|
continue;
|
|
469
488
|
}
|
|
470
|
-
if (
|
|
489
|
+
if (isNoiseLine(trimmed)) {
|
|
471
490
|
continue;
|
|
472
491
|
}
|
|
473
492
|
if (trimmed.startsWith("❯")) {
|
|
@@ -535,16 +554,6 @@ export class ClaudePtyBridge extends EventEmitter {
|
|
|
535
554
|
});
|
|
536
555
|
}
|
|
537
556
|
// ── Text Processing Utilities ──
|
|
538
|
-
stripAnsi(text) {
|
|
539
|
-
// eslint-disable-next-line no-control-regex
|
|
540
|
-
return text
|
|
541
|
-
.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "") // CSI sequences
|
|
542
|
-
.replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, "") // OSC sequences
|
|
543
|
-
.replace(/\x1b[><=ePX^_]/g, "") // Single-char escapes
|
|
544
|
-
// eslint-disable-next-line no-control-regex
|
|
545
|
-
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "") // Control chars (keep \t \n \r)
|
|
546
|
-
.replace(/\r\n?/g, "\n");
|
|
547
|
-
}
|
|
548
557
|
/**
|
|
549
558
|
* Find the end index of the echoed user input in the PTY buffer.
|
|
550
559
|
* The echo may contain ANSI codes between characters.
|
|
@@ -575,47 +584,8 @@ export class ClaudePtyBridge extends EventEmitter {
|
|
|
575
584
|
}
|
|
576
585
|
return matchedChars === inputChars.length ? endIndex : 0;
|
|
577
586
|
}
|
|
578
|
-
isStatusLine(line) {
|
|
579
|
-
if (!line)
|
|
580
|
-
return false;
|
|
581
|
-
if (line.startsWith("────"))
|
|
582
|
-
return true;
|
|
583
|
-
if (line === "❯")
|
|
584
|
-
return true;
|
|
585
|
-
if (line.includes("esc to interrupt"))
|
|
586
|
-
return true;
|
|
587
|
-
if (line.includes("Claude Code v"))
|
|
588
|
-
return true;
|
|
589
|
-
if (line.includes("Failed to install Anthropic"))
|
|
590
|
-
return true;
|
|
591
|
-
if (line.includes("Claude Code has switched"))
|
|
592
|
-
return true;
|
|
593
|
-
if (line.includes("[wand]"))
|
|
594
|
-
return true;
|
|
595
|
-
if (line.includes("Captured Claude session ID"))
|
|
596
|
-
return true;
|
|
597
|
-
if (line.includes("ctrl+g"))
|
|
598
|
-
return true;
|
|
599
|
-
if (line.includes("/effort"))
|
|
600
|
-
return true;
|
|
601
|
-
if (line.includes("? for shortcuts"))
|
|
602
|
-
return true;
|
|
603
|
-
if (line.includes("auto mode is unavailable"))
|
|
604
|
-
return true;
|
|
605
|
-
if (/MCP server.*failed/i.test(line))
|
|
606
|
-
return true;
|
|
607
|
-
if (line.includes("Germinating") || line.includes("Doodling") || line.includes("Brewing"))
|
|
608
|
-
return true;
|
|
609
|
-
if (line.includes("Permissions") && line.includes("mode"))
|
|
610
|
-
return true;
|
|
611
|
-
if (line.startsWith("●") && line.includes("·"))
|
|
612
|
-
return true;
|
|
613
|
-
if (line.startsWith("[>") || line.startsWith("[<"))
|
|
614
|
-
return true;
|
|
615
|
-
return false;
|
|
616
|
-
}
|
|
617
587
|
cleanForChat(raw) {
|
|
618
|
-
const text =
|
|
588
|
+
const text = stripAnsi(raw);
|
|
619
589
|
const lines = text.split("\n");
|
|
620
590
|
const cleanLines = [];
|
|
621
591
|
for (const rawLine of lines) {
|
|
@@ -629,7 +599,7 @@ export class ClaudePtyBridge extends EventEmitter {
|
|
|
629
599
|
if (trimmed === this.chatState.lastUserInput.trim()) {
|
|
630
600
|
continue;
|
|
631
601
|
}
|
|
632
|
-
if (
|
|
602
|
+
if (isNoiseLine(trimmed)) {
|
|
633
603
|
continue;
|
|
634
604
|
}
|
|
635
605
|
if (trimmed.startsWith("❯")) {
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
|
|
2
2
|
export {};
|
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env -S node --disable-warning=ExperimentalWarning
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { ensureConfig, hasConfigFile, isExecutionMode, resolveConfigPath, saveConfig } from "./config.js";
|
|
4
4
|
import { startServer } from "./server.js";
|
|
@@ -105,12 +105,20 @@ function setConfigValue(config, key, value) {
|
|
|
105
105
|
};
|
|
106
106
|
case "defaultMode":
|
|
107
107
|
if (!isExecutionMode(value)) {
|
|
108
|
-
throw new Error(
|
|
108
|
+
throw new Error(`defaultMode must be one of: assist, agent, agent-max, auto-edit, default, full-access, managed, native`);
|
|
109
109
|
}
|
|
110
110
|
return {
|
|
111
111
|
...config,
|
|
112
112
|
defaultMode: value
|
|
113
113
|
};
|
|
114
|
+
case "https":
|
|
115
|
+
if (value !== "true" && value !== "false") {
|
|
116
|
+
throw new Error("https must be 'true' or 'false'");
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
...config,
|
|
120
|
+
https: value === "true"
|
|
121
|
+
};
|
|
114
122
|
default:
|
|
115
123
|
throw new Error(`Unsupported config key: ${key}`);
|
|
116
124
|
}
|
package/dist/config.js
CHANGED
|
@@ -7,7 +7,7 @@ const DEFAULT_CONFIG_FILE = "config.json";
|
|
|
7
7
|
export const defaultConfig = () => ({
|
|
8
8
|
host: "127.0.0.1",
|
|
9
9
|
port: 8443,
|
|
10
|
-
https:
|
|
10
|
+
https: false,
|
|
11
11
|
password: "change-me",
|
|
12
12
|
defaultMode: "default",
|
|
13
13
|
shell: process.env.SHELL || "/bin/bash",
|
|
@@ -60,7 +60,11 @@ export async function ensureConfig(configPath) {
|
|
|
60
60
|
try {
|
|
61
61
|
const raw = await readFile(configPath, "utf8");
|
|
62
62
|
const merged = mergeWithDefaults(JSON.parse(raw));
|
|
63
|
-
|
|
63
|
+
const normalized = `${JSON.stringify(merged, null, 2)}\n`;
|
|
64
|
+
// Only write if the file content actually changed
|
|
65
|
+
if (raw.trimEnd() !== normalized.trimEnd()) {
|
|
66
|
+
await writeFile(configPath, normalized, "utf8");
|
|
67
|
+
}
|
|
64
68
|
return merged;
|
|
65
69
|
}
|
|
66
70
|
catch {
|
package/dist/message-parser.js
CHANGED
|
@@ -1,90 +1,4 @@
|
|
|
1
|
-
|
|
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
|
-
continue;
|
|
35
|
-
}
|
|
36
|
-
// Skip control characters except \n, \r, \t
|
|
37
|
-
if (ch < 32 && ch !== 10 && ch !== 13 && ch !== 9)
|
|
38
|
-
continue;
|
|
39
|
-
stripped += text.charAt(i);
|
|
40
|
-
}
|
|
41
|
-
return stripped;
|
|
42
|
-
}
|
|
43
|
-
/** Lines considered as UI noise (pass in trimmed) */
|
|
44
|
-
function isNoiseLine(line) {
|
|
45
|
-
if (line.length === 0)
|
|
46
|
-
return false;
|
|
47
|
-
if (line.startsWith("────"))
|
|
48
|
-
return true;
|
|
49
|
-
if (line === "❯")
|
|
50
|
-
return true;
|
|
51
|
-
if (line.includes("esc to interrupt"))
|
|
52
|
-
return true;
|
|
53
|
-
if (line.includes("Claude Code v"))
|
|
54
|
-
return true;
|
|
55
|
-
if (/^Sonnet\b/.test(line))
|
|
56
|
-
return true;
|
|
57
|
-
if (line.includes("Failed to install Anthropic"))
|
|
58
|
-
return true;
|
|
59
|
-
if (line.includes("Claude Code has switched"))
|
|
60
|
-
return true;
|
|
61
|
-
if (line.includes("? for shortcuts"))
|
|
62
|
-
return true;
|
|
63
|
-
if (line.includes("Claude is waiting"))
|
|
64
|
-
return true;
|
|
65
|
-
if (line.includes("[wand]"))
|
|
66
|
-
return true;
|
|
67
|
-
if (line.startsWith("0;") || line.startsWith("9;"))
|
|
68
|
-
return true;
|
|
69
|
-
if (line.includes("ctrl+g"))
|
|
70
|
-
return true;
|
|
71
|
-
if (line.includes("/effort"))
|
|
72
|
-
return true;
|
|
73
|
-
if (/^Using .* for .* session/.test(line))
|
|
74
|
-
return true;
|
|
75
|
-
if (line.startsWith("Press ") && line.includes(" for"))
|
|
76
|
-
return true;
|
|
77
|
-
if (line.startsWith("type ") && line.includes(" to "))
|
|
78
|
-
return true;
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
function isAssistantContent(line) {
|
|
82
|
-
if (line.startsWith("❯"))
|
|
83
|
-
return false;
|
|
84
|
-
if (line.includes("esctointerrupt"))
|
|
85
|
-
return false;
|
|
86
|
-
return true;
|
|
87
|
-
}
|
|
1
|
+
import { stripAnsi, isNoiseLine } from "./pty-text-utils.js";
|
|
88
2
|
export function parseMessages(output) {
|
|
89
3
|
const messages = [];
|
|
90
4
|
if (!output)
|
|
@@ -118,7 +32,7 @@ export function parseMessages(output) {
|
|
|
118
32
|
currentUserText = null;
|
|
119
33
|
}
|
|
120
34
|
}
|
|
121
|
-
else if (currentUserText !== null
|
|
35
|
+
else if (currentUserText !== null) {
|
|
122
36
|
const contentLine = rawLine.startsWith("⏺") ? rawLine.slice(1) : rawLine;
|
|
123
37
|
currentAssistantLines.push(contentLine);
|
|
124
38
|
}
|
|
@@ -126,10 +40,16 @@ export function parseMessages(output) {
|
|
|
126
40
|
if (currentUserText !== null && currentAssistantLines.length > 0) {
|
|
127
41
|
turns.push({ user: currentUserText, assistantLines: currentAssistantLines });
|
|
128
42
|
}
|
|
43
|
+
else if (currentUserText !== null) {
|
|
44
|
+
// User input exists but no assistant response yet — still record the turn
|
|
45
|
+
turns.push({ user: currentUserText, assistantLines: currentAssistantLines });
|
|
46
|
+
}
|
|
129
47
|
for (const turn of turns) {
|
|
130
48
|
messages.push({ role: "user", content: turn.user });
|
|
131
49
|
const content = turn.assistantLines.join("\n").replace(/[ \t]+\n/g, "\n").replace(/[\n\s]+$/, "");
|
|
132
|
-
|
|
50
|
+
if (content) {
|
|
51
|
+
messages.push({ role: "assistant", content });
|
|
52
|
+
}
|
|
133
53
|
}
|
|
134
54
|
return messages;
|
|
135
55
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Check that targetPath is within basePath (or equal to it). */
|
|
2
|
+
export declare function isPathWithinBase(targetPath: string, basePath: string): boolean;
|
|
3
|
+
/** Check if targetPath is inside any blocked system folder. */
|
|
4
|
+
export declare function isBlockedFolderPath(targetPath: string): boolean;
|
|
5
|
+
/** Normalize a folder path to its absolute form. */
|
|
6
|
+
export declare function normalizeFolderPath(inputPath: string): string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
/** Check that targetPath is within basePath (or equal to it). */
|
|
3
|
+
export function isPathWithinBase(targetPath, basePath) {
|
|
4
|
+
const relativePath = path.relative(basePath, targetPath);
|
|
5
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
|
6
|
+
}
|
|
7
|
+
/** Blocked folder paths that should never be browsed. */
|
|
8
|
+
const BLOCKED_FOLDER_PATHS = ["/etc", "/root", "/boot"];
|
|
9
|
+
/** Check if targetPath is inside any blocked system folder. */
|
|
10
|
+
export function isBlockedFolderPath(targetPath) {
|
|
11
|
+
return BLOCKED_FOLDER_PATHS.some((blockedPath) => {
|
|
12
|
+
const relativePath = path.relative(blockedPath, targetPath);
|
|
13
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
/** Normalize a folder path to its absolute form. */
|
|
17
|
+
export function normalizeFolderPath(inputPath) {
|
|
18
|
+
return path.resolve(inputPath);
|
|
19
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login rate limiter — tracks failed attempts per IP.
|
|
3
|
+
* In-memory only; resets on process restart.
|
|
4
|
+
*/
|
|
5
|
+
export declare function checkRateLimit(ip: string): boolean;
|
|
6
|
+
export declare function recordFailedLogin(ip: string): void;
|
|
7
|
+
export declare function resetRateLimit(ip: string): void;
|
|
8
|
+
export declare function cleanupRateLimiter(): void;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login rate limiter — tracks failed attempts per IP.
|
|
3
|
+
* In-memory only; resets on process restart.
|
|
4
|
+
*/
|
|
5
|
+
const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
|
|
6
|
+
const RATE_LIMIT_MAX = 10; // 10 attempts per window
|
|
7
|
+
const loginAttempts = new Map();
|
|
8
|
+
export function checkRateLimit(ip) {
|
|
9
|
+
const now = Date.now();
|
|
10
|
+
const record = loginAttempts.get(ip);
|
|
11
|
+
if (!record || now > record.resetAt) {
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
return record.count < RATE_LIMIT_MAX;
|
|
15
|
+
}
|
|
16
|
+
export function recordFailedLogin(ip) {
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
const record = loginAttempts.get(ip);
|
|
19
|
+
if (!record || now > record.resetAt) {
|
|
20
|
+
loginAttempts.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
record.count++;
|
|
24
|
+
}
|
|
25
|
+
export function resetRateLimit(ip) {
|
|
26
|
+
loginAttempts.delete(ip);
|
|
27
|
+
}
|
|
28
|
+
export function cleanupRateLimiter() {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
for (const [ip, record] of loginAttempts.entries()) {
|
|
31
|
+
if (now > record.resetAt) {
|
|
32
|
+
loginAttempts.delete(ip);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Cleanup expired entries every 5 minutes
|
|
37
|
+
setInterval(cleanupRateLimiter, 5 * 60 * 1000);
|