@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 CHANGED
@@ -8,7 +8,7 @@
8
8
  - 会话持久化与恢复
9
9
  - Claude Code 集成
10
10
  - 文件浏览器
11
- - HTTPS 安全连接
11
+ - HTTPS 安全连接(可选,配置 `https: true` 启用)
12
12
 
13
13
  ## 快速开始
14
14
 
@@ -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 = 4096;
13
+ const SESSION_ID_WINDOW_SIZE = 16384;
13
14
  const PERMISSION_WINDOW_SIZE = 800;
14
- const CLAUDE_SESSION_ID_PATTERN = /"session_id"\s*:\s*"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"/i;
15
- // Patterns for permission detection
16
- const PERMISSION_PATTERNS = [
17
- /(?:^|\b)(?:press\s+)?(?:y|yes)\s*(?:\/|\bor\b)\s*(?:n|no)(?:\b|$)/i,
18
- /\[(?:y|yes)\s*\/\s*(?:n|no)\]/i,
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 = CLAUDE_SESSION_ID_PATTERN.exec(this.sessionIdWindow);
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 (this.permissionState.isBlocked === blocked)
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 = this.stripAnsi(this.chatState.buffer);
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 (this.isStatusLine(trimmed)) {
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 = this.stripAnsi(raw);
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 (this.isStatusLine(trimmed)) {
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("defaultMode must be auto-edit, default, or full-access");
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: true,
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
- await writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
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 {
@@ -1,90 +1,4 @@
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
- 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 && isAssistantContent(line)) {
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
- messages.push({ role: "assistant", content });
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);