@co0ontty/wand 1.0.1 → 1.1.1

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
@@ -10,7 +10,13 @@
10
10
  - 文件浏览器
11
11
  - HTTPS 安全连接(可选,配置 `https: true` 启用)
12
12
 
13
- ## 快速开始
13
+ ## 一键安装
14
+
15
+ ```bash
16
+ bash <(curl -Ls https://raw.githubusercontent.com/co0ontty/wand/master/install.sh)
17
+ ```
18
+
19
+ ## 手动安装
14
20
 
15
21
  ```bash
16
22
  npm install -g @co0ontty/wand
package/dist/avatar.d.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  export declare function ensureAvatarSeed(configDir: string): Promise<string>;
10
10
  /**
11
11
  * Generate an SVG identicon from a seed string.
12
- * Uses a 5x5 symmetric grid (mirrored to 9 columns), GitHub-style.
12
+ * Uses a 5x5 symmetric grid with white cells on a colored background.
13
+ * The pattern is mirrored horizontally for visual balance.
13
14
  */
14
15
  export declare function getAvatarSvg(seed: string, size: 192 | 512): string;
package/dist/avatar.js CHANGED
@@ -37,68 +37,60 @@ function generateRandomSeed() {
37
37
  }
38
38
  /**
39
39
  * Generate an SVG identicon from a seed string.
40
- * Uses a 5x5 symmetric grid (mirrored to 9 columns), GitHub-style.
40
+ * Uses a 5x5 symmetric grid with white cells on a colored background.
41
+ * The pattern is mirrored horizontally for visual balance.
41
42
  */
42
43
  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
44
  const svgSize = size;
48
- // Derive color from seed
45
+ const gridSize = 5;
46
+ const padding = Math.round(svgSize * 0.12);
47
+ const innerSize = svgSize - padding * 2;
48
+ const cellSize = Math.floor(innerSize / gridSize);
49
+ const gridWidth = cellSize * gridSize;
50
+ const gridOffset = (svgSize - gridWidth) / 2;
51
+ // Derive color from seed — warm tones that match the Wand theme
49
52
  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)
53
+ const s = 55 + (Math.abs(hashString(seed + "s")) % 30);
54
+ const l = 42 + (Math.abs(hashString(seed + "l")) % 16);
55
+ const bgColor = `hsl(${h},${s}%,${l}%)`;
56
+ // Derive fill states from seed
54
57
  const seedChars = seed.slice(0, 25);
55
58
  const cells = [];
56
59
  for (let i = 0; i < 25; i++) {
57
60
  const char = seedChars[i] || "0";
58
61
  cells.push(parseInt(char, 16) % 2 === 0);
59
62
  }
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
63
+ const parts = [];
64
+ const cornerR = Math.round(svgSize * 0.14);
65
+ // Full SVG background with rounded corners
66
+ parts.push(`<rect width="${svgSize}" height="${svgSize}" rx="${cornerR}" fill="${bgColor}"/>`);
67
+ // White cells on colored background — high contrast
68
+ const cellR = Math.round(cellSize * 0.12);
69
+ const cellGap = Math.max(1, Math.round(cellSize * 0.08));
70
+ const actualCell = cellSize - cellGap;
67
71
  for (let row = 0; row < gridSize; row++) {
68
72
  for (let col = 0; col < gridSize; col++) {
69
73
  const mirrorCol = gridSize - 1 - col;
70
74
  const idx = row * gridSize + col;
71
- // Only draw for left half + middle column (right half mirrors)
72
75
  if (col > mirrorCol)
73
76
  continue;
74
77
  if (!cells[idx])
75
78
  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);
79
+ const y = gridOffset + row * cellSize + cellGap / 2;
85
80
  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}"/>`);
81
+ const x = gridOffset + col * cellSize + cellGap / 2;
82
+ parts.push(`<rect x="${x}" y="${y}" width="${actualCell}" height="${actualCell}" rx="${cellR}" fill="rgba(255,255,255,0.9)"/>`);
90
83
  }
91
84
  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}"/>`);
85
+ const x1 = gridOffset + col * cellSize + cellGap / 2;
86
+ const x2 = gridOffset + mirrorCol * cellSize + cellGap / 2;
87
+ parts.push(`<rect x="${x1}" y="${y}" width="${actualCell}" height="${actualCell}" rx="${cellR}" fill="rgba(255,255,255,0.9)"/>`);
88
+ parts.push(`<rect x="${x2}" y="${y}" width="${actualCell}" height="${actualCell}" rx="${cellR}" fill="rgba(255,255,255,0.9)"/>`);
97
89
  }
98
90
  }
99
91
  }
100
92
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgSize} ${svgSize}" width="${svgSize}" height="${svgSize}">
101
- ${rects.join("\n ")}
93
+ ${parts.join("\n ")}
102
94
  </svg>`;
103
95
  }
104
96
  function hashString(s) {
@@ -53,7 +53,7 @@ export declare class ProcessManager extends EventEmitter {
53
53
  private static readonly HISTORY_CACHE_TTL_MS;
54
54
  listClaudeHistorySessions(): ClaudeHistorySession[];
55
55
  get(id: string): SessionSnapshot | null;
56
- sendInput(id: string, input: string, view?: "chat" | "terminal"): SessionSnapshot;
56
+ sendInput(id: string, input: string, view?: "chat" | "terminal", shortcutKey?: string): SessionSnapshot;
57
57
  /** Emit a task event for a session, debounced to avoid flooding */
58
58
  private emitTask;
59
59
  resize(id: string, cols: number, rows: number): SessionSnapshot;
@@ -1147,7 +1147,7 @@ export class ProcessManager extends EventEmitter {
1147
1147
  }
1148
1148
  return this.snapshot(record);
1149
1149
  }
1150
- sendInput(id, input, view) {
1150
+ sendInput(id, input, view, shortcutKey) {
1151
1151
  const record = this.mustGet(id);
1152
1152
  if (record.status !== "running") {
1153
1153
  console.error("[ProcessManager] Rejecting input for non-running session", {
@@ -1179,6 +1179,12 @@ export class ProcessManager extends EventEmitter {
1179
1179
  inputLength: input.length,
1180
1180
  view: view ?? "chat"
1181
1181
  });
1182
+ // Log shortcut key interactions in managed/full-access modes for auto-confirm analysis
1183
+ if (shortcutKey && record.autoApprovePermissions) {
1184
+ const outputLines = record.output.split("\n");
1185
+ const tailLines = outputLines.slice(-15).join("\n");
1186
+ this.logger.appendShortcutLog(id, shortcutKey, tailLines);
1187
+ }
1182
1188
  // Track user input via bridge for Chat mode
1183
1189
  if (record.ptyBridge) {
1184
1190
  record.ptyBridge.onUserInput(input);
@@ -1740,10 +1746,15 @@ export class ProcessManager extends EventEmitter {
1740
1746
  }
1741
1747
  return false;
1742
1748
  }
1743
- processCommandForMode(command, _mode) {
1744
- // Don't automatically add --enable-auto-mode as it may not be available
1745
- // for all plans and can cause issues with normal interactive mode.
1746
- // Let users specify it explicitly if they want auto mode.
1749
+ processCommandForMode(command, mode) {
1750
+ // In managed mode, append a system prompt instructing Claude to act autonomously
1751
+ // without asking the user for confirmation, since the user may not be monitoring.
1752
+ if (mode === "managed" && /^claude(?:\s|$)/.test(command)) {
1753
+ const autonomousPrompt = "You are running in a fully managed, autonomous mode. The user may not be available to respond to questions or confirmations in a timely manner. You MUST make all decisions independently — choose the best approach yourself instead of asking the user for preferences, confirmations, or clarifications. If multiple approaches are viable, pick the one you judge most appropriate and proceed. Never block on user input unless the task is fundamentally ambiguous and cannot be reasonably inferred. Be decisive and self-directed.";
1754
+ // Escape single quotes for shell safety
1755
+ const escaped = autonomousPrompt.replace(/'/g, "'\\''");
1756
+ return `${command} --append-system-prompt '${escaped}'`;
1757
+ }
1747
1758
  return command;
1748
1759
  }
1749
1760
  }
package/dist/pwa.js CHANGED
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * PWA manifest and Service Worker generation.
3
3
  */
4
+ /** Cache version is fixed per server process to avoid mid-session cache busting */
5
+ const CACHE_VERSION = Date.now().toString(36);
4
6
  export function generatePwaManifest() {
5
7
  return JSON.stringify({
6
8
  id: "/wand",
@@ -20,6 +22,8 @@ export function generatePwaManifest() {
20
22
  icons: [
21
23
  { src: "/icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" },
22
24
  { src: "/icon.svg", sizes: "any", type: "image/svg+xml", purpose: "maskable" },
25
+ { src: "/icon-192.png", sizes: "192x192", type: "image/svg+xml", purpose: "any" },
26
+ { src: "/icon-512.png", sizes: "512x512", type: "image/svg+xml", purpose: "any" },
23
27
  ],
24
28
  categories: ["developer tools", "productivity"],
25
29
  shortcuts: [
@@ -34,8 +38,8 @@ export function generatePwaManifest() {
34
38
  }
35
39
  export function generateServiceWorker() {
36
40
  return `
37
- const STATIC_CACHE = 'wand-static-v6';
38
- const RUNTIME_CACHE = 'wand-runtime-v6';
41
+ const STATIC_CACHE = 'wand-static-${CACHE_VERSION}';
42
+ const RUNTIME_CACHE = 'wand-runtime-${CACHE_VERSION}';
39
43
  const APP_SHELL = '/';
40
44
  const STATIC_ASSETS = [
41
45
  '/manifest.json',
package/dist/server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import express from "express";
2
2
  import { readdir, readFile, stat } from "node:fs/promises";
3
- import { existsSync } from "node:fs";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { createServer as createHttpServer } from "node:http";
5
5
  import { createServer as createHttpsServer } from "node:https";
6
6
  import { exec } from "node:child_process";
@@ -11,10 +11,49 @@ import { WebSocketServer } from "ws";
11
11
  const execAsync = promisify(exec);
12
12
  const SERVER_MODULE_DIR = path.dirname(new URL(import.meta.url).pathname);
13
13
  const RUNTIME_ROOT_DIR = path.resolve(SERVER_MODULE_DIR, "..");
14
+ // ── Package info ──
15
+ const PKG_JSON = JSON.parse(readFileSync(path.join(RUNTIME_ROOT_DIR, "package.json"), "utf8"));
16
+ const PKG_NAME = PKG_JSON.name;
17
+ const PKG_VERSION = PKG_JSON.version;
18
+ const PKG_NODE_REQ = PKG_JSON.engines?.node ?? ">=22.5.0";
19
+ const PKG_REPO_URL = "https://github.com/co0ontty/wand";
20
+ // ── Update check cache ──
21
+ let cachedLatestVersion = null;
22
+ let cacheTimestamp = 0;
23
+ const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
24
+ async function checkNpmLatestVersion(forceRefresh = false) {
25
+ const now = Date.now();
26
+ if (forceRefresh || !cachedLatestVersion || (now - cacheTimestamp > CACHE_TTL_MS)) {
27
+ try {
28
+ const { stdout } = await execAsync(`npm view ${PKG_NAME} version`, { timeout: 15000 });
29
+ cachedLatestVersion = stdout.trim();
30
+ cacheTimestamp = now;
31
+ }
32
+ catch {
33
+ cachedLatestVersion = null;
34
+ }
35
+ }
36
+ const latest = cachedLatestVersion || PKG_VERSION;
37
+ return {
38
+ current: PKG_VERSION,
39
+ latest,
40
+ updateAvailable: latest !== PKG_VERSION && compareSemver(latest, PKG_VERSION) > 0,
41
+ };
42
+ }
43
+ function compareSemver(a, b) {
44
+ const pa = a.split(".").map(Number);
45
+ const pb = b.split(".").map(Number);
46
+ for (let i = 0; i < 3; i++) {
47
+ const diff = (pa[i] || 0) - (pb[i] || 0);
48
+ if (diff !== 0)
49
+ return diff;
50
+ }
51
+ return 0;
52
+ }
14
53
  import { ensureAvatarSeed, getAvatarSvg } from "./avatar.js";
15
54
  import { createSession, revokeSession, setAuthStorage, validateSession } from "./auth.js";
16
55
  import { ensureCertificates } from "./cert.js";
17
- import { isExecutionMode, resolveConfigDir } from "./config.js";
56
+ import { isExecutionMode, resolveConfigDir, saveConfig } from "./config.js";
18
57
  import { ProcessManager, SessionInputError } from "./process-manager.js";
19
58
  import { resolveDatabasePath, WandStorage } from "./storage.js";
20
59
  import { renderApp } from "./web-ui/index.js";
@@ -337,6 +376,116 @@ export async function startServer(config, configPath) {
337
376
  commandPresets: config.commandPresets,
338
377
  });
339
378
  });
379
+ // ── Settings endpoints ──
380
+ app.get("/api/settings", (_req, res) => {
381
+ const certPaths = {
382
+ keyPath: path.join(configDir, "server.key"),
383
+ certPath: path.join(configDir, "server.crt"),
384
+ };
385
+ const { password: _pw, ...safeConfig } = config;
386
+ res.json({
387
+ version: PKG_VERSION,
388
+ packageName: PKG_NAME,
389
+ nodeVersion: PKG_NODE_REQ,
390
+ repoUrl: PKG_REPO_URL,
391
+ config: safeConfig,
392
+ hasCert: existsSync(certPaths.keyPath) && existsSync(certPaths.certPath),
393
+ });
394
+ });
395
+ app.post("/api/settings/config", async (req, res) => {
396
+ const body = req.body;
397
+ const allowedFields = ["host", "port", "https", "defaultMode", "defaultCwd", "shell"];
398
+ let changed = false;
399
+ for (const field of allowedFields) {
400
+ if (field in body && body[field] !== undefined) {
401
+ if (field === "port") {
402
+ const p = Number(body.port);
403
+ if (!Number.isInteger(p) || p < 1 || p > 65535) {
404
+ res.status(400).json({ error: `无效端口号: ${body.port}` });
405
+ return;
406
+ }
407
+ config.port = p;
408
+ }
409
+ else if (field === "https") {
410
+ config.https = body.https === true;
411
+ }
412
+ else if (field === "defaultMode") {
413
+ if (!isExecutionMode(body.defaultMode)) {
414
+ res.status(400).json({ error: `无效执行模式: ${body.defaultMode}` });
415
+ return;
416
+ }
417
+ config.defaultMode = body.defaultMode;
418
+ }
419
+ else if (field === "host") {
420
+ config.host = String(body.host);
421
+ }
422
+ else if (field === "defaultCwd") {
423
+ config.defaultCwd = String(body.defaultCwd);
424
+ }
425
+ else if (field === "shell") {
426
+ config.shell = String(body.shell);
427
+ }
428
+ changed = true;
429
+ }
430
+ }
431
+ if (!changed) {
432
+ res.status(400).json({ error: "没有可更新的配置字段。" });
433
+ return;
434
+ }
435
+ try {
436
+ await saveConfig(configPath, config);
437
+ const { password: _pw, ...safeConfig } = config;
438
+ res.json({ ok: true, config: safeConfig, restartRequired: true });
439
+ }
440
+ catch (error) {
441
+ res.status(500).json({ error: getErrorMessage(error, "保存配置失败。") });
442
+ }
443
+ });
444
+ app.post("/api/settings/upload-cert", async (req, res) => {
445
+ const { key, cert } = req.body;
446
+ if (!key || !cert) {
447
+ res.status(400).json({ error: "请提供 key 和 cert 内容。" });
448
+ return;
449
+ }
450
+ if (!key.includes("-----BEGIN") || !cert.includes("-----BEGIN")) {
451
+ res.status(400).json({ error: "证书内容格式无效,请上传 PEM 格式的文件。" });
452
+ return;
453
+ }
454
+ try {
455
+ const keyPath = path.join(configDir, "server.key");
456
+ const certPath = path.join(configDir, "server.crt");
457
+ writeFileSync(keyPath, key, { mode: 0o600 });
458
+ writeFileSync(certPath, cert, { mode: 0o644 });
459
+ res.json({ ok: true, restartRequired: true });
460
+ }
461
+ catch (error) {
462
+ res.status(500).json({ error: getErrorMessage(error, "保存证书失败。") });
463
+ }
464
+ });
465
+ app.get("/api/check-update", async (_req, res) => {
466
+ try {
467
+ const result = await checkNpmLatestVersion(true);
468
+ res.json(result);
469
+ }
470
+ catch (error) {
471
+ res.status(500).json({ error: getErrorMessage(error, "检查更新失败。") });
472
+ }
473
+ });
474
+ app.post("/api/update", async (_req, res) => {
475
+ try {
476
+ const { updateAvailable } = await checkNpmLatestVersion();
477
+ if (!updateAvailable) {
478
+ res.json({ ok: true, message: "已经是最新版本。" });
479
+ return;
480
+ }
481
+ res.json({ ok: true, message: "正在更新,请稍候..." });
482
+ // Run update in background — the server will restart
483
+ execAsync(`npm install -g ${PKG_NAME}@latest`, { timeout: 120000 }).catch(() => { });
484
+ }
485
+ catch (error) {
486
+ res.status(500).json({ error: getErrorMessage(error, "更新失败。") });
487
+ }
488
+ });
340
489
  app.get("/api/sessions", (_req, res) => {
341
490
  res.json(processes.list());
342
491
  });
@@ -870,9 +1019,10 @@ export async function startServer(config, configPath) {
870
1019
  const sessionId = req.params.id;
871
1020
  const input = body.input ?? "";
872
1021
  const view = body.view;
1022
+ const shortcutKey = body.shortcutKey;
873
1023
  console.error("[wand] Input request received", { sessionId, inputLength: input.length, view: view ?? "chat" });
874
1024
  try {
875
- const snapshot = processes.sendInput(sessionId, input, view);
1025
+ const snapshot = processes.sendInput(sessionId, input, view, shortcutKey);
876
1026
  console.error("[wand] Input request succeeded", { sessionId, status: snapshot.status, inputLength: input.length, view: view ?? "chat" });
877
1027
  res.json(snapshot);
878
1028
  }
@@ -974,4 +1124,10 @@ export async function startServer(config, configPath) {
974
1124
  }
975
1125
  // Start configured background sessions after the server is already reachable.
976
1126
  processes.runStartupCommands();
1127
+ // Background update check on startup
1128
+ checkNpmLatestVersion().then(({ current, latest, updateAvailable }) => {
1129
+ if (updateAvailable) {
1130
+ process.stdout.write(`[wand] 发现新版本 ${latest}(当前 ${current})。运行 npm install -g ${PKG_NAME}@latest 进行更新。\n`);
1131
+ }
1132
+ }).catch(() => { });
977
1133
  }
@@ -30,4 +30,6 @@ export declare class SessionLogger {
30
30
  saveMetadata(sessionId: string, meta: Record<string, unknown>): void;
31
31
  /** Delete all log files for a session */
32
32
  deleteSession(sessionId: string): void;
33
+ /** Append a shortcut key interaction log entry (for analyzing auto-confirm gaps) */
34
+ appendShortcutLog(sessionId: string, shortcutKey: string, tailLines: string): void;
33
35
  }
@@ -126,4 +126,19 @@ export class SessionLogger {
126
126
  }
127
127
  this.dirs.delete(sessionId);
128
128
  }
129
+ /** Append a shortcut key interaction log entry (for analyzing auto-confirm gaps) */
130
+ appendShortcutLog(sessionId, shortcutKey, tailLines) {
131
+ try {
132
+ const dir = this.ensureDir(sessionId);
133
+ const entry = JSON.stringify({
134
+ ts: new Date().toISOString(),
135
+ key: shortcutKey,
136
+ tail: tailLines,
137
+ }) + "\n";
138
+ appendFileSync(path.join(dir, "shortcut-interactions.jsonl"), entry);
139
+ }
140
+ catch {
141
+ // Non-critical
142
+ }
143
+ }
129
144
  }
package/dist/types.d.ts CHANGED
@@ -55,6 +55,8 @@ export interface InputRequest {
55
55
  approvalPolicy?: ApprovalPolicy;
56
56
  allowedScopes?: EscalationScope[];
57
57
  turn?: TurnRequest;
58
+ /** Shortcut key name that triggered this input (e.g. "enter", "yes", "ctrl_c"). Used for interaction logging in managed/full-access modes. */
59
+ shortcutKey?: string;
58
60
  }
59
61
  export interface ResizeRequest {
60
62
  cols?: number;