@co0ontty/wand 0.2.1 → 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 +25 -5
- package/dist/acp-protocol.d.ts +67 -0
- package/dist/acp-protocol.js +291 -0
- package/dist/avatar.d.ts +14 -0
- package/dist/avatar.js +110 -0
- package/dist/claude-pty-bridge.d.ts +137 -0
- package/dist/claude-pty-bridge.js +619 -0
- package/dist/claude-stream-adapter.d.ts +35 -0
- package/dist/claude-stream-adapter.js +153 -0
- package/dist/claude-structured-runner.d.ts +27 -0
- package/dist/claude-structured-runner.js +106 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +10 -2
- package/dist/config.js +8 -4
- package/dist/message-parser.js +16 -150
- package/dist/message-queue.d.ts +57 -0
- package/dist/message-queue.js +127 -0
- 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 +82 -27
- package/dist/process-manager.js +1445 -822
- 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 +511 -409
- package/dist/session-lifecycle.d.ts +81 -0
- package/dist/session-lifecycle.js +181 -0
- 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 +73 -7
- package/dist/types.d.ts +112 -6
- 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 +3770 -852
- package/dist/web-ui/content/styles.css +5505 -2779
- package/dist/web-ui/index.js +8 -5
- package/dist/web-ui/scripts.js +8 -1
- package/dist/ws-broadcast.d.ts +27 -0
- package/dist/ws-broadcast.js +160 -0
- package/package.json +2 -9
- package/dist/web-ui/utils.d.ts +0 -4
- package/dist/web-ui/utils.js +0 -12
- package/dist/web-ui.d.ts +0 -1
- package/dist/web-ui.js +0 -2
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message Queue for managing user inputs
|
|
3
|
+
* Inspired by Happy's MessageQueue2 implementation
|
|
4
|
+
*/
|
|
5
|
+
export class MessageQueue {
|
|
6
|
+
queue = [];
|
|
7
|
+
processing = false;
|
|
8
|
+
onMessageCallback = null;
|
|
9
|
+
lastMessageId = 0;
|
|
10
|
+
/**
|
|
11
|
+
* Add a message to the queue
|
|
12
|
+
*/
|
|
13
|
+
enqueue(content, options = {}) {
|
|
14
|
+
const message = {
|
|
15
|
+
id: `msg-${++this.lastMessageId}-${Date.now()}`,
|
|
16
|
+
content,
|
|
17
|
+
priority: 1,
|
|
18
|
+
timestamp: Date.now(),
|
|
19
|
+
metadata: {
|
|
20
|
+
autonomyPolicy: options.autonomyPolicy,
|
|
21
|
+
approvalPolicy: options.approvalPolicy,
|
|
22
|
+
allowedScopes: options.allowedScopes,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
this.queue.push(message);
|
|
26
|
+
this.processNext();
|
|
27
|
+
return message;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Add a high-priority message (like /compact, /clear)
|
|
31
|
+
*/
|
|
32
|
+
enqueuePriority(content, options = {}) {
|
|
33
|
+
const message = {
|
|
34
|
+
id: `msg-priority-${++this.lastMessageId}-${Date.now()}`,
|
|
35
|
+
content,
|
|
36
|
+
priority: 10,
|
|
37
|
+
timestamp: Date.now(),
|
|
38
|
+
isolate: true,
|
|
39
|
+
metadata: {
|
|
40
|
+
autonomyPolicy: options.autonomyPolicy,
|
|
41
|
+
approvalPolicy: options.approvalPolicy,
|
|
42
|
+
allowedScopes: options.allowedScopes,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
// Insert at the beginning of the queue
|
|
46
|
+
this.queue.unshift(message);
|
|
47
|
+
this.processNext();
|
|
48
|
+
return message;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Clear the queue and add a new message
|
|
52
|
+
* Used for /compact and /clear commands
|
|
53
|
+
*/
|
|
54
|
+
clearAndEnqueue(content, options = {}) {
|
|
55
|
+
const clearedCount = this.queue.length;
|
|
56
|
+
this.queue = [];
|
|
57
|
+
if (clearedCount > 0) {
|
|
58
|
+
console.error(`[MessageQueue] Cleared ${clearedCount} pending messages`);
|
|
59
|
+
}
|
|
60
|
+
return this.enqueuePriority(content, options);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Set the message handler
|
|
64
|
+
*/
|
|
65
|
+
onMessage(handler) {
|
|
66
|
+
this.onMessageCallback = handler;
|
|
67
|
+
this.processNext();
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Process the next message in the queue
|
|
71
|
+
*/
|
|
72
|
+
async processNext() {
|
|
73
|
+
if (this.processing || this.queue.length === 0 || !this.onMessageCallback) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this.processing = true;
|
|
77
|
+
// Sort by priority (higher first), then by timestamp
|
|
78
|
+
this.queue.sort((a, b) => {
|
|
79
|
+
if (a.priority !== b.priority) {
|
|
80
|
+
return b.priority - a.priority;
|
|
81
|
+
}
|
|
82
|
+
return a.timestamp - b.timestamp;
|
|
83
|
+
});
|
|
84
|
+
const message = this.queue.shift();
|
|
85
|
+
try {
|
|
86
|
+
await this.onMessageCallback(message);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
console.error(`[MessageQueue] Error processing message ${message.id}:`, error);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
this.processing = false;
|
|
93
|
+
// Process next message
|
|
94
|
+
this.processNext();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Get the current queue length
|
|
99
|
+
*/
|
|
100
|
+
get length() {
|
|
101
|
+
return this.queue.length;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Check if the queue is empty
|
|
105
|
+
*/
|
|
106
|
+
get isEmpty() {
|
|
107
|
+
return this.queue.length === 0;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Check if a message is being processed
|
|
111
|
+
*/
|
|
112
|
+
get isProcessing() {
|
|
113
|
+
return this.processing;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Clear all pending messages
|
|
117
|
+
*/
|
|
118
|
+
clear() {
|
|
119
|
+
this.queue = [];
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Get all pending messages (for debugging)
|
|
123
|
+
*/
|
|
124
|
+
getPending() {
|
|
125
|
+
return [...this.queue];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -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);
|
|
@@ -2,58 +2,113 @@ import { EventEmitter } from "node:events";
|
|
|
2
2
|
import { WandStorage } from "./storage.js";
|
|
3
3
|
import { ExecutionMode, SessionSnapshot, WandConfig } from "./types.js";
|
|
4
4
|
export interface ProcessEvent {
|
|
5
|
-
type: "output" | "status" | "started" | "ended";
|
|
5
|
+
type: "output" | "status" | "started" | "ended" | "usage" | "task";
|
|
6
6
|
sessionId: string;
|
|
7
7
|
data?: unknown;
|
|
8
8
|
}
|
|
9
|
+
/** Human-readable task information for the UI */
|
|
10
|
+
export interface TaskInfo {
|
|
11
|
+
title: string;
|
|
12
|
+
tool?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare class SessionInputError extends Error {
|
|
15
|
+
readonly code: "SESSION_NOT_FOUND" | "SESSION_NOT_RUNNING" | "SESSION_NO_PTY";
|
|
16
|
+
readonly sessionId: string;
|
|
17
|
+
readonly sessionStatus?: SessionSnapshot["status"] | undefined;
|
|
18
|
+
constructor(message: string, code: "SESSION_NOT_FOUND" | "SESSION_NOT_RUNNING" | "SESSION_NO_PTY", sessionId: string, sessionStatus?: SessionSnapshot["status"] | undefined);
|
|
19
|
+
}
|
|
9
20
|
export type ProcessEventHandler = (event: ProcessEvent) => void;
|
|
21
|
+
/** A Claude Code session discovered by scanning ~/.claude/projects/ directories. */
|
|
22
|
+
export interface ClaudeHistorySession {
|
|
23
|
+
claudeSessionId: string;
|
|
24
|
+
projectDir: string;
|
|
25
|
+
cwd: string;
|
|
26
|
+
firstUserMessage: string;
|
|
27
|
+
timestamp: string;
|
|
28
|
+
mtimeMs: number;
|
|
29
|
+
hasConversation: boolean;
|
|
30
|
+
managedByWand: boolean;
|
|
31
|
+
}
|
|
10
32
|
export declare class ProcessManager extends EventEmitter {
|
|
11
33
|
private readonly config;
|
|
12
34
|
private readonly storage;
|
|
13
35
|
private readonly sessions;
|
|
14
36
|
private readonly logger;
|
|
37
|
+
private readonly lifecycleManager;
|
|
38
|
+
/** Per-session debounce timers for throttled persist calls */
|
|
39
|
+
private readonly persistDebounceTimers;
|
|
40
|
+
/** Last persisted message count per session — used to skip redundant file writes */
|
|
41
|
+
private readonly lastPersistedMessageCount;
|
|
15
42
|
constructor(config: WandConfig, storage: WandStorage, configDir?: string);
|
|
16
43
|
on(event: "process", listener: ProcessEventHandler): this;
|
|
17
44
|
private emitEvent;
|
|
18
45
|
private cleanupOldSessions;
|
|
19
|
-
start(command: string, cwd: string | undefined, mode: ExecutionMode, initialInput?: string
|
|
46
|
+
start(command: string, cwd: string | undefined, mode: ExecutionMode, initialInput?: string, opts?: {
|
|
47
|
+
resumedFromSessionId?: string;
|
|
48
|
+
autoRecovered?: boolean;
|
|
49
|
+
}): SessionSnapshot;
|
|
20
50
|
list(): SessionSnapshot[];
|
|
51
|
+
hasClaudeSessionFile(cwd: string, claudeSessionId: string): boolean;
|
|
52
|
+
private claudeHistoryCache;
|
|
53
|
+
private static readonly HISTORY_CACHE_TTL_MS;
|
|
54
|
+
listClaudeHistorySessions(): ClaudeHistorySession[];
|
|
21
55
|
get(id: string): SessionSnapshot | null;
|
|
22
56
|
sendInput(id: string, input: string, view?: "chat" | "terminal"): SessionSnapshot;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
* Wrap user message with autonomous completion instructions for managed mode.
|
|
26
|
-
* The AI is told to complete the task in one shot without asking questions.
|
|
27
|
-
*/
|
|
28
|
-
private wrapManagedPrompt;
|
|
29
|
-
private processJsonEvent;
|
|
30
|
-
private appendBlockToOutput;
|
|
31
|
-
private buildContentBlocks;
|
|
32
|
-
private buildAssistantTurn;
|
|
33
|
-
/** Determine if input looks like a real chat message (not control characters) */
|
|
34
|
-
private isRealChatInput;
|
|
35
|
-
/** Check if a command is a Claude CLI command */
|
|
36
|
-
private isClaudeCommand;
|
|
37
|
-
/** Strip ANSI escape sequences from raw PTY output */
|
|
38
|
-
private stripAnsiSequences;
|
|
39
|
-
/** Track and update assistant response from PTY output */
|
|
40
|
-
private trackPtyAssistantResponse;
|
|
41
|
-
/** Update the assistant placeholder message with cleaned PTY content */
|
|
42
|
-
private updatePtyAssistantContent;
|
|
43
|
-
/** Finalize the assistant message when ❯ prompt is detected */
|
|
44
|
-
private finalizePtyAssistantMessage;
|
|
45
|
-
/** Clean raw PTY output into readable chat content */
|
|
46
|
-
private cleanPtyOutputForChat;
|
|
57
|
+
/** Emit a task event for a session, debounced to avoid flooding */
|
|
58
|
+
private emitTask;
|
|
47
59
|
resize(id: string, cols: number, rows: number): SessionSnapshot;
|
|
48
60
|
stop(id: string): SessionSnapshot;
|
|
49
61
|
delete(id: string): void;
|
|
50
|
-
|
|
62
|
+
private deleteClaudeCache;
|
|
63
|
+
runStartupCommands(): SessionSnapshot[];
|
|
51
64
|
private snapshot;
|
|
65
|
+
private isPermissionBlocked;
|
|
66
|
+
private defaultAutonomyPolicy;
|
|
67
|
+
resolveEscalation(id: string, requestId: string, resolution?: "approve_once" | "approve_turn" | "deny"): SessionSnapshot;
|
|
68
|
+
approvePermission(id: string): SessionSnapshot;
|
|
69
|
+
denyPermission(id: string): SessionSnapshot;
|
|
70
|
+
/**
|
|
71
|
+
* Canonical permission resolution method.
|
|
72
|
+
* All other permission methods delegate to this.
|
|
73
|
+
* @param resolution - "approve_once", "approve_turn", or "deny"
|
|
74
|
+
* @param requestId - Optional escalation request ID for validation
|
|
75
|
+
*/
|
|
76
|
+
resolvePermission(id: string, resolution: "approve_once" | "approve_turn" | "deny", requestId?: string): SessionSnapshot;
|
|
52
77
|
private persist;
|
|
78
|
+
/**
|
|
79
|
+
* Schedule a debounced persist call for the given record.
|
|
80
|
+
* Multiple calls within the debounce window are coalesced into a single write.
|
|
81
|
+
* Use this in hot paths (e.g. onData) to reduce I/O pressure.
|
|
82
|
+
*/
|
|
83
|
+
private schedulePersist;
|
|
84
|
+
/**
|
|
85
|
+
* Immediately persist any pending debounced write and clear the timer.
|
|
86
|
+
* Use this at critical points (exit, stop, delete) to ensure no data loss.
|
|
87
|
+
*/
|
|
88
|
+
private flushPersist;
|
|
89
|
+
private backfillExitedClaudeSessionIds;
|
|
90
|
+
/**
|
|
91
|
+
* Auto-recover the most recent exited session that has a Claude session ID.
|
|
92
|
+
* Only resumes one session per server start, using the most recent eligible
|
|
93
|
+
* session. Sets `resumedToSessionId` on the original session and
|
|
94
|
+
* `autoRecovered: true` on the new session.
|
|
95
|
+
*/
|
|
96
|
+
private autoRecoverExitedSessions;
|
|
53
97
|
private archiveExpiredSessions;
|
|
54
98
|
private assertCommandAllowed;
|
|
99
|
+
/**
|
|
100
|
+
* @deprecated Only retained for non-Claude-CLI sessions without ptyBridge.
|
|
101
|
+
* For Claude CLI sessions, auto-approval is handled by ClaudePtyBridge.detectPermission().
|
|
102
|
+
*/
|
|
55
103
|
private autoConfirmWithRecord;
|
|
104
|
+
/**
|
|
105
|
+
* Handle events from ClaudePtyBridge
|
|
106
|
+
*/
|
|
107
|
+
private handleBridgeEvent;
|
|
108
|
+
/** Check if a command is a Claude CLI command */
|
|
109
|
+
private isClaudeCommand;
|
|
56
110
|
private mustGet;
|
|
57
111
|
private buildShellArgs;
|
|
112
|
+
private shouldAutoApprovePermissions;
|
|
58
113
|
private processCommandForMode;
|
|
59
114
|
}
|