@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,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Lifecycle Manager
|
|
3
|
+
* Inspired by Happy's session lifecycle management
|
|
4
|
+
*/
|
|
5
|
+
import type { SessionLifecycleState, SessionLifecycle } from "./types.js";
|
|
6
|
+
export interface SessionLifecycleEvents {
|
|
7
|
+
onStateChange?: (sessionId: string, oldState: SessionLifecycleState, newState: SessionLifecycleState) => void;
|
|
8
|
+
onIdle?: (sessionId: string) => void;
|
|
9
|
+
onArchived?: (sessionId: string, reason: string) => void;
|
|
10
|
+
}
|
|
11
|
+
export declare class SessionLifecycleManager {
|
|
12
|
+
private sessions;
|
|
13
|
+
private events;
|
|
14
|
+
private idleTimeout;
|
|
15
|
+
private archiveTimeout;
|
|
16
|
+
private checkInterval;
|
|
17
|
+
constructor(events?: SessionLifecycleEvents, options?: {
|
|
18
|
+
idleTimeout?: number;
|
|
19
|
+
archiveTimeout?: number;
|
|
20
|
+
});
|
|
21
|
+
/**
|
|
22
|
+
* Register a new session
|
|
23
|
+
*/
|
|
24
|
+
register(sessionId: string, initialState?: SessionLifecycleState): void;
|
|
25
|
+
/**
|
|
26
|
+
* Update session state
|
|
27
|
+
*/
|
|
28
|
+
setState(sessionId: string, newState: SessionLifecycleState): void;
|
|
29
|
+
/**
|
|
30
|
+
* Update last activity timestamp
|
|
31
|
+
*/
|
|
32
|
+
touch(sessionId: string): void;
|
|
33
|
+
/**
|
|
34
|
+
* Mark session as thinking
|
|
35
|
+
*/
|
|
36
|
+
startThinking(sessionId: string): void;
|
|
37
|
+
/**
|
|
38
|
+
* Mark session as done thinking
|
|
39
|
+
*/
|
|
40
|
+
stopThinking(sessionId: string): void;
|
|
41
|
+
/**
|
|
42
|
+
* Mark session as waiting for input
|
|
43
|
+
*/
|
|
44
|
+
waitingInput(sessionId: string): void;
|
|
45
|
+
/**
|
|
46
|
+
* Archive a session
|
|
47
|
+
*/
|
|
48
|
+
archive(sessionId: string, reason: string, by?: "user" | "timeout" | "error"): void;
|
|
49
|
+
/**
|
|
50
|
+
* Unregister a session
|
|
51
|
+
*/
|
|
52
|
+
unregister(sessionId: string): void;
|
|
53
|
+
/**
|
|
54
|
+
* Get session lifecycle
|
|
55
|
+
*/
|
|
56
|
+
get(sessionId: string): SessionLifecycle | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* Get all sessions
|
|
59
|
+
*/
|
|
60
|
+
getAll(): Map<string, SessionLifecycle>;
|
|
61
|
+
/**
|
|
62
|
+
* Get sessions by state
|
|
63
|
+
*/
|
|
64
|
+
getByState(state: SessionLifecycleState): string[];
|
|
65
|
+
/**
|
|
66
|
+
* Start periodic check for idle/archived sessions
|
|
67
|
+
*/
|
|
68
|
+
private startPeriodicCheck;
|
|
69
|
+
/**
|
|
70
|
+
* Stop periodic check
|
|
71
|
+
*/
|
|
72
|
+
stopPeriodicCheck(): void;
|
|
73
|
+
/**
|
|
74
|
+
* Check sessions for idle/archived status
|
|
75
|
+
*/
|
|
76
|
+
private checkSessions;
|
|
77
|
+
/**
|
|
78
|
+
* Cleanup all sessions
|
|
79
|
+
*/
|
|
80
|
+
cleanup(): void;
|
|
81
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Lifecycle Manager
|
|
3
|
+
* Inspired by Happy's session lifecycle management
|
|
4
|
+
*/
|
|
5
|
+
export class SessionLifecycleManager {
|
|
6
|
+
sessions = new Map();
|
|
7
|
+
events;
|
|
8
|
+
idleTimeout;
|
|
9
|
+
archiveTimeout;
|
|
10
|
+
checkInterval = null;
|
|
11
|
+
constructor(events = {}, options = {}) {
|
|
12
|
+
this.events = events;
|
|
13
|
+
this.idleTimeout = options.idleTimeout ?? 5 * 60 * 1000; // 5 minutes
|
|
14
|
+
this.archiveTimeout = options.archiveTimeout ?? 30 * 60 * 1000; // 30 minutes
|
|
15
|
+
// Start periodic check
|
|
16
|
+
this.startPeriodicCheck();
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Register a new session
|
|
20
|
+
*/
|
|
21
|
+
register(sessionId, initialState = "initializing") {
|
|
22
|
+
const lifecycle = {
|
|
23
|
+
state: initialState,
|
|
24
|
+
stateSince: Date.now(),
|
|
25
|
+
lastActivityAt: Date.now(),
|
|
26
|
+
};
|
|
27
|
+
this.sessions.set(sessionId, lifecycle);
|
|
28
|
+
console.error(`[Lifecycle] Session ${sessionId} registered with state: ${initialState}`);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Update session state
|
|
32
|
+
*/
|
|
33
|
+
setState(sessionId, newState) {
|
|
34
|
+
const lifecycle = this.sessions.get(sessionId);
|
|
35
|
+
if (!lifecycle) {
|
|
36
|
+
console.error(`[Lifecycle] Session ${sessionId} not found`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const oldState = lifecycle.state;
|
|
40
|
+
if (oldState === newState) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
lifecycle.state = newState;
|
|
44
|
+
lifecycle.stateSince = Date.now();
|
|
45
|
+
lifecycle.lastActivityAt = Date.now();
|
|
46
|
+
console.error(`[Lifecycle] Session ${sessionId} state changed: ${oldState} -> ${newState}`);
|
|
47
|
+
// Emit state change event
|
|
48
|
+
this.events.onStateChange?.(sessionId, oldState, newState);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Update last activity timestamp
|
|
52
|
+
*/
|
|
53
|
+
touch(sessionId) {
|
|
54
|
+
const lifecycle = this.sessions.get(sessionId);
|
|
55
|
+
if (lifecycle) {
|
|
56
|
+
lifecycle.lastActivityAt = Date.now();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Mark session as thinking
|
|
61
|
+
*/
|
|
62
|
+
startThinking(sessionId) {
|
|
63
|
+
this.setState(sessionId, "thinking");
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Mark session as done thinking
|
|
67
|
+
*/
|
|
68
|
+
stopThinking(sessionId) {
|
|
69
|
+
const lifecycle = this.sessions.get(sessionId);
|
|
70
|
+
if (lifecycle?.state === "thinking") {
|
|
71
|
+
this.setState(sessionId, "idle");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Mark session as waiting for input
|
|
76
|
+
*/
|
|
77
|
+
waitingInput(sessionId) {
|
|
78
|
+
this.setState(sessionId, "waiting-input");
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Archive a session
|
|
82
|
+
*/
|
|
83
|
+
archive(sessionId, reason, by = "user") {
|
|
84
|
+
const lifecycle = this.sessions.get(sessionId);
|
|
85
|
+
if (!lifecycle) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
lifecycle.state = "archived";
|
|
89
|
+
lifecycle.stateSince = Date.now();
|
|
90
|
+
lifecycle.archivedBy = by;
|
|
91
|
+
lifecycle.archiveReason = reason;
|
|
92
|
+
console.error(`[Lifecycle] Session ${sessionId} archived: ${reason} (by: ${by})`);
|
|
93
|
+
// Emit archived event
|
|
94
|
+
this.events.onArchived?.(sessionId, reason);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Unregister a session
|
|
98
|
+
*/
|
|
99
|
+
unregister(sessionId) {
|
|
100
|
+
this.sessions.delete(sessionId);
|
|
101
|
+
console.error(`[Lifecycle] Session ${sessionId} unregistered`);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Get session lifecycle
|
|
105
|
+
*/
|
|
106
|
+
get(sessionId) {
|
|
107
|
+
return this.sessions.get(sessionId);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Get all sessions
|
|
111
|
+
*/
|
|
112
|
+
getAll() {
|
|
113
|
+
return new Map(this.sessions);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Get sessions by state
|
|
117
|
+
*/
|
|
118
|
+
getByState(state) {
|
|
119
|
+
const result = [];
|
|
120
|
+
for (const [sessionId, lifecycle] of this.sessions) {
|
|
121
|
+
if (lifecycle.state === state) {
|
|
122
|
+
result.push(sessionId);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Start periodic check for idle/archived sessions
|
|
129
|
+
*/
|
|
130
|
+
startPeriodicCheck() {
|
|
131
|
+
if (this.checkInterval) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
this.checkInterval = setInterval(() => {
|
|
135
|
+
this.checkSessions();
|
|
136
|
+
}, 60 * 1000); // Check every minute
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Stop periodic check
|
|
140
|
+
*/
|
|
141
|
+
stopPeriodicCheck() {
|
|
142
|
+
if (this.checkInterval) {
|
|
143
|
+
clearInterval(this.checkInterval);
|
|
144
|
+
this.checkInterval = null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Check sessions for idle/archived status
|
|
149
|
+
*/
|
|
150
|
+
checkSessions() {
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
for (const [sessionId, lifecycle] of this.sessions) {
|
|
153
|
+
try {
|
|
154
|
+
if (lifecycle.state === "archived") {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const timeSinceLastActivity = now - lifecycle.lastActivityAt;
|
|
158
|
+
// Check for archive timeout
|
|
159
|
+
if (timeSinceLastActivity > this.archiveTimeout) {
|
|
160
|
+
this.archive(sessionId, "Session timed out", "timeout");
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
// Check for idle timeout
|
|
164
|
+
if (timeSinceLastActivity > this.idleTimeout && lifecycle.state !== "idle") {
|
|
165
|
+
this.setState(sessionId, "idle");
|
|
166
|
+
this.events.onIdle?.(sessionId);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
console.error(`[Lifecycle] Error checking session ${sessionId}: ${String(err)}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Cleanup all sessions
|
|
176
|
+
*/
|
|
177
|
+
cleanup() {
|
|
178
|
+
this.stopPeriodicCheck();
|
|
179
|
+
this.sessions.clear();
|
|
180
|
+
}
|
|
181
|
+
}
|
package/dist/session-logger.d.ts
CHANGED
|
@@ -3,15 +3,23 @@ import type { ConversationTurn } from "./types.js";
|
|
|
3
3
|
* SessionLogger saves raw session content to local files for debugging and analysis.
|
|
4
4
|
*
|
|
5
5
|
* Directory structure: .wand/sessions/{sessionId}/
|
|
6
|
-
* - pty-output.log
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
6
|
+
* - pty-output.log Raw PTY output (current, rotated when > 50 MB)
|
|
7
|
+
* - pty-output.log.1..3 Rotated PTY output backups
|
|
8
|
+
* - stream-events.jsonl NDJSON events from native mode (append-only)
|
|
9
|
+
* - messages.json Final structured messages (overwritten on each update)
|
|
9
10
|
*/
|
|
10
11
|
export declare class SessionLogger {
|
|
11
12
|
private readonly baseDir;
|
|
12
13
|
private readonly dirs;
|
|
13
14
|
constructor(configDir: string);
|
|
14
15
|
private ensureDir;
|
|
16
|
+
/**
|
|
17
|
+
* Rotate PTY log files if the current one exceeds the size limit.
|
|
18
|
+
* pty-output.log.2 → pty-output.log.3 (deleted if at max)
|
|
19
|
+
* pty-output.log.1 → pty-output.log.2
|
|
20
|
+
* pty-output.log → pty-output.log.1
|
|
21
|
+
*/
|
|
22
|
+
private rotatePtyLog;
|
|
15
23
|
/** Append raw PTY output chunk */
|
|
16
24
|
appendPtyOutput(sessionId: string, chunk: string): void;
|
|
17
25
|
/** Append a native mode NDJSON event */
|
|
@@ -20,4 +28,6 @@ export declare class SessionLogger {
|
|
|
20
28
|
saveMessages(sessionId: string, messages: ConversationTurn[]): void;
|
|
21
29
|
/** Save session metadata */
|
|
22
30
|
saveMetadata(sessionId: string, meta: Record<string, unknown>): void;
|
|
31
|
+
/** Delete all log files for a session */
|
|
32
|
+
deleteSession(sessionId: string): void;
|
|
23
33
|
}
|
package/dist/session-logger.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
import { mkdirSync, appendFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, rmSync, appendFileSync, writeFileSync, existsSync, statSync, renameSync, unlinkSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
+
// ── Constants ──
|
|
5
|
+
/** Max size for a single PTY log file before rotation (50 MB) */
|
|
6
|
+
const PTY_LOG_MAX_SIZE = 50 * 1024 * 1024;
|
|
7
|
+
/** Maximum number of rotated log files to keep */
|
|
8
|
+
const PTY_LOG_MAX_ROTATIONS = 3;
|
|
4
9
|
/**
|
|
5
10
|
* SessionLogger saves raw session content to local files for debugging and analysis.
|
|
6
11
|
*
|
|
7
12
|
* Directory structure: .wand/sessions/{sessionId}/
|
|
8
|
-
* - pty-output.log
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
13
|
+
* - pty-output.log Raw PTY output (current, rotated when > 50 MB)
|
|
14
|
+
* - pty-output.log.1..3 Rotated PTY output backups
|
|
15
|
+
* - stream-events.jsonl NDJSON events from native mode (append-only)
|
|
16
|
+
* - messages.json Final structured messages (overwritten on each update)
|
|
11
17
|
*/
|
|
12
18
|
export class SessionLogger {
|
|
13
19
|
baseDir;
|
|
@@ -35,11 +41,45 @@ export class SessionLogger {
|
|
|
35
41
|
this.dirs.set(sessionId, dir);
|
|
36
42
|
return dir;
|
|
37
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Rotate PTY log files if the current one exceeds the size limit.
|
|
46
|
+
* pty-output.log.2 → pty-output.log.3 (deleted if at max)
|
|
47
|
+
* pty-output.log.1 → pty-output.log.2
|
|
48
|
+
* pty-output.log → pty-output.log.1
|
|
49
|
+
*/
|
|
50
|
+
rotatePtyLog(dir) {
|
|
51
|
+
// Delete oldest if it exists (beyond max rotations)
|
|
52
|
+
const oldest = path.join(dir, `pty-output.log.${PTY_LOG_MAX_ROTATIONS}`);
|
|
53
|
+
if (existsSync(oldest)) {
|
|
54
|
+
unlinkSync(oldest);
|
|
55
|
+
}
|
|
56
|
+
// Shift existing rotations up by one
|
|
57
|
+
for (let i = PTY_LOG_MAX_ROTATIONS - 1; i >= 1; i--) {
|
|
58
|
+
const src = path.join(dir, `pty-output.log.${i}`);
|
|
59
|
+
const dst = path.join(dir, `pty-output.log.${i + 1}`);
|
|
60
|
+
if (existsSync(src)) {
|
|
61
|
+
renameSync(src, dst);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Rotate current to .1
|
|
65
|
+
const current = path.join(dir, "pty-output.log");
|
|
66
|
+
if (existsSync(current)) {
|
|
67
|
+
renameSync(current, path.join(dir, "pty-output.log.1"));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
38
70
|
/** Append raw PTY output chunk */
|
|
39
71
|
appendPtyOutput(sessionId, chunk) {
|
|
40
72
|
try {
|
|
41
73
|
const dir = this.ensureDir(sessionId);
|
|
42
|
-
|
|
74
|
+
const logPath = path.join(dir, "pty-output.log");
|
|
75
|
+
// Check size and rotate if needed
|
|
76
|
+
if (existsSync(logPath)) {
|
|
77
|
+
const stats = statSync(logPath);
|
|
78
|
+
if (stats.size >= PTY_LOG_MAX_SIZE) {
|
|
79
|
+
this.rotatePtyLog(dir);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
appendFileSync(logPath, chunk);
|
|
43
83
|
}
|
|
44
84
|
catch {
|
|
45
85
|
// Non-critical — don't let logging failures affect main flow
|
|
@@ -75,4 +115,15 @@ export class SessionLogger {
|
|
|
75
115
|
// Non-critical
|
|
76
116
|
}
|
|
77
117
|
}
|
|
118
|
+
/** Delete all log files for a session */
|
|
119
|
+
deleteSession(sessionId) {
|
|
120
|
+
const dir = path.join(this.baseDir, sessionId);
|
|
121
|
+
try {
|
|
122
|
+
rmSync(dir, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Non-critical
|
|
126
|
+
}
|
|
127
|
+
this.dirs.delete(sessionId);
|
|
128
|
+
}
|
|
78
129
|
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -27,6 +27,15 @@ export declare class WandStorage {
|
|
|
27
27
|
deleteAuthSession(token: string): void;
|
|
28
28
|
deleteExpiredAuthSessions(now: number): void;
|
|
29
29
|
saveSession(snapshot: SessionSnapshot): void;
|
|
30
|
+
/**
|
|
31
|
+
* Lightweight update — only touches scalar session fields, skips messages.
|
|
32
|
+
* Use this in the hot persist path to avoid serializing large message arrays.
|
|
33
|
+
* Full messages are written by saveSession() at state transitions (exit/stop).
|
|
34
|
+
*/
|
|
35
|
+
saveSessionMetadata(snapshot: SessionSnapshot): void;
|
|
36
|
+
getSession(id: string): SessionSnapshot | null;
|
|
37
|
+
getLatestSessionByClaudeSessionId(claudeSessionId: string): SessionSnapshot | null;
|
|
30
38
|
loadSessions(): SessionSnapshot[];
|
|
39
|
+
private mapSessionRow;
|
|
31
40
|
deleteSession(id: string): void;
|
|
32
41
|
}
|
package/dist/storage.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
function parseStoredMessages(raw) {
|
|
5
|
+
if (!raw) {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(raw);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
4
15
|
export const DEFAULT_DB_FILE = "wand.db";
|
|
5
16
|
export function resolveDatabasePath(configPath) {
|
|
6
17
|
return path.resolve(path.dirname(configPath), DEFAULT_DB_FILE);
|
|
@@ -113,9 +124,10 @@ export class WandStorage {
|
|
|
113
124
|
try {
|
|
114
125
|
this.db
|
|
115
126
|
.prepare(`INSERT INTO command_sessions (
|
|
116
|
-
|
|
127
|
+
id, command, cwd, mode, status, exit_code, started_at, ended_at, output
|
|
117
128
|
, archived, archived_at, claude_session_id, messages
|
|
118
|
-
|
|
129
|
+
, resumed_from_session_id, resumed_to_session_id, auto_recovered
|
|
130
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
119
131
|
ON CONFLICT(id) DO UPDATE SET
|
|
120
132
|
command = excluded.command,
|
|
121
133
|
cwd = excluded.cwd,
|
|
@@ -128,8 +140,11 @@ export class WandStorage {
|
|
|
128
140
|
archived = excluded.archived,
|
|
129
141
|
archived_at = excluded.archived_at,
|
|
130
142
|
claude_session_id = excluded.claude_session_id,
|
|
131
|
-
messages = excluded.messages
|
|
132
|
-
|
|
143
|
+
messages = excluded.messages,
|
|
144
|
+
resumed_from_session_id = excluded.resumed_from_session_id,
|
|
145
|
+
resumed_to_session_id = excluded.resumed_to_session_id,
|
|
146
|
+
auto_recovered = excluded.auto_recovered`)
|
|
147
|
+
.run(snapshot.id, snapshot.command, snapshot.cwd, snapshot.mode, snapshot.status, snapshot.exitCode, snapshot.startedAt, snapshot.endedAt, snapshot.output, snapshot.archived ? 1 : 0, snapshot.archivedAt, snapshot.claudeSessionId, snapshot.messages ? JSON.stringify(snapshot.messages) : null, snapshot.resumedFromSessionId ?? null, snapshot.resumedToSessionId ?? null, snapshot.autoRecovered ? 1 : 0);
|
|
133
148
|
this.db.exec("COMMIT");
|
|
134
149
|
}
|
|
135
150
|
catch (error) {
|
|
@@ -137,13 +152,52 @@ export class WandStorage {
|
|
|
137
152
|
throw error;
|
|
138
153
|
}
|
|
139
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Lightweight update — only touches scalar session fields, skips messages.
|
|
157
|
+
* Use this in the hot persist path to avoid serializing large message arrays.
|
|
158
|
+
* Full messages are written by saveSession() at state transitions (exit/stop).
|
|
159
|
+
*/
|
|
160
|
+
saveSessionMetadata(snapshot) {
|
|
161
|
+
this.db
|
|
162
|
+
.prepare(`UPDATE command_sessions SET
|
|
163
|
+
command = ?, cwd = ?, mode = ?, status = ?, exit_code = ?,
|
|
164
|
+
started_at = ?, ended_at = ?, output = ?,
|
|
165
|
+
archived = ?, archived_at = ?, claude_session_id = ?,
|
|
166
|
+
resumed_from_session_id = ?, resumed_to_session_id = ?, auto_recovered = ?
|
|
167
|
+
WHERE id = ?`)
|
|
168
|
+
.run(snapshot.command, snapshot.cwd, snapshot.mode, snapshot.status, snapshot.exitCode, snapshot.startedAt, snapshot.endedAt, snapshot.output, snapshot.archived ? 1 : 0, snapshot.archivedAt, snapshot.claudeSessionId, snapshot.resumedFromSessionId ?? null, snapshot.resumedToSessionId ?? null, snapshot.autoRecovered ? 1 : 0, snapshot.id);
|
|
169
|
+
}
|
|
170
|
+
getSession(id) {
|
|
171
|
+
const row = this.db
|
|
172
|
+
.prepare(`SELECT id, command, cwd, mode, status, exit_code, started_at, ended_at, output, archived, archived_at, claude_session_id, messages
|
|
173
|
+
, resumed_from_session_id, resumed_to_session_id, auto_recovered
|
|
174
|
+
FROM command_sessions
|
|
175
|
+
WHERE id = ?`)
|
|
176
|
+
.get(id);
|
|
177
|
+
return row ? this.mapSessionRow(row) : null;
|
|
178
|
+
}
|
|
179
|
+
getLatestSessionByClaudeSessionId(claudeSessionId) {
|
|
180
|
+
const row = this.db
|
|
181
|
+
.prepare(`SELECT id, command, cwd, mode, status, exit_code, started_at, ended_at, output, archived, archived_at, claude_session_id, messages
|
|
182
|
+
, resumed_from_session_id, resumed_to_session_id, auto_recovered
|
|
183
|
+
FROM command_sessions
|
|
184
|
+
WHERE claude_session_id = ?
|
|
185
|
+
ORDER BY started_at DESC
|
|
186
|
+
LIMIT 1`)
|
|
187
|
+
.get(claudeSessionId);
|
|
188
|
+
return row ? this.mapSessionRow(row) : null;
|
|
189
|
+
}
|
|
140
190
|
loadSessions() {
|
|
141
191
|
const rows = this.db
|
|
142
192
|
.prepare(`SELECT id, command, cwd, mode, status, exit_code, started_at, ended_at, output, archived, archived_at, claude_session_id, messages
|
|
193
|
+
, resumed_from_session_id, resumed_to_session_id, auto_recovered
|
|
143
194
|
FROM command_sessions
|
|
144
195
|
ORDER BY started_at DESC`)
|
|
145
196
|
.all();
|
|
146
|
-
return rows.map((row) => (
|
|
197
|
+
return rows.map((row) => this.mapSessionRow(row));
|
|
198
|
+
}
|
|
199
|
+
mapSessionRow(row) {
|
|
200
|
+
return {
|
|
147
201
|
id: row.id,
|
|
148
202
|
command: row.command,
|
|
149
203
|
cwd: row.cwd,
|
|
@@ -156,8 +210,11 @@ export class WandStorage {
|
|
|
156
210
|
archived: Boolean(row.archived),
|
|
157
211
|
archivedAt: row.archived_at,
|
|
158
212
|
claudeSessionId: row.claude_session_id,
|
|
159
|
-
messages:
|
|
160
|
-
|
|
213
|
+
messages: parseStoredMessages(row.messages),
|
|
214
|
+
resumedFromSessionId: row.resumed_from_session_id ?? undefined,
|
|
215
|
+
resumedToSessionId: row.resumed_to_session_id ?? undefined,
|
|
216
|
+
autoRecovered: Boolean(row.auto_recovered)
|
|
217
|
+
};
|
|
161
218
|
}
|
|
162
219
|
deleteSession(id) {
|
|
163
220
|
this.db.prepare("DELETE FROM command_sessions WHERE id = ?").run(id);
|
|
@@ -178,4 +235,13 @@ function ensureCommandSessionSchema(db) {
|
|
|
178
235
|
if (!names.has("messages")) {
|
|
179
236
|
db.exec("ALTER TABLE command_sessions ADD COLUMN messages TEXT");
|
|
180
237
|
}
|
|
238
|
+
if (!names.has("resumed_from_session_id")) {
|
|
239
|
+
db.exec("ALTER TABLE command_sessions ADD COLUMN resumed_from_session_id TEXT");
|
|
240
|
+
}
|
|
241
|
+
if (!names.has("resumed_to_session_id")) {
|
|
242
|
+
db.exec("ALTER TABLE command_sessions ADD COLUMN resumed_to_session_id TEXT");
|
|
243
|
+
}
|
|
244
|
+
if (!names.has("auto_recovered")) {
|
|
245
|
+
db.exec("ALTER TABLE command_sessions ADD COLUMN auto_recovered INTEGER NOT NULL DEFAULT 0");
|
|
246
|
+
}
|
|
181
247
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,28 @@
|
|
|
1
|
-
export type ExecutionMode = "
|
|
1
|
+
export type ExecutionMode = "assist" | "agent" | "agent-max" | "default" | "auto-edit" | "full-access" | "native" | "managed";
|
|
2
|
+
export type AutonomyPolicy = "assist" | "agent" | "agent-max";
|
|
3
|
+
export type ApprovalPolicy = "ask-every-time" | "approve-once" | "remember-this-turn";
|
|
4
|
+
export type EscalationScope = "write_file" | "run_command" | "network" | "outside_workspace" | "dangerous_shell" | "unknown";
|
|
5
|
+
export type EscalationRunner = "json" | "pty";
|
|
6
|
+
export type EscalationResolution = "approve_once" | "approve_turn" | "deny" | "fallback_manual";
|
|
7
|
+
export type EscalationSource = "tool_permission_request" | "sandbox_hard_block" | "workspace_policy_limit" | "cli_capability_limit" | "unknown";
|
|
8
|
+
export interface EscalationRequest {
|
|
9
|
+
requestId: string;
|
|
10
|
+
scope: EscalationScope;
|
|
11
|
+
runner: EscalationRunner;
|
|
12
|
+
source: EscalationSource;
|
|
13
|
+
resolution?: EscalationResolution;
|
|
14
|
+
target?: string;
|
|
15
|
+
reason: string;
|
|
16
|
+
}
|
|
17
|
+
export interface TurnRequest {
|
|
18
|
+
message: string;
|
|
19
|
+
autonomyPolicy?: AutonomyPolicy;
|
|
20
|
+
approvalPolicy?: ApprovalPolicy;
|
|
21
|
+
allowedScopes?: EscalationScope[];
|
|
22
|
+
}
|
|
23
|
+
export interface EscalationDecisionRequest {
|
|
24
|
+
resolution?: Extract<EscalationResolution, "approve_once" | "approve_turn" | "deny">;
|
|
25
|
+
}
|
|
2
26
|
export interface CommandPreset {
|
|
3
27
|
label: string;
|
|
4
28
|
command: string;
|
|
@@ -7,7 +31,7 @@ export interface CommandPreset {
|
|
|
7
31
|
export interface WandConfig {
|
|
8
32
|
host: string;
|
|
9
33
|
port: number;
|
|
10
|
-
/** Enable HTTPS with self-signed certificate (default:
|
|
34
|
+
/** Enable HTTPS with self-signed certificate (default: false) */
|
|
11
35
|
https?: boolean;
|
|
12
36
|
password: string;
|
|
13
37
|
defaultMode: ExecutionMode;
|
|
@@ -25,8 +49,12 @@ export interface CommandRequest {
|
|
|
25
49
|
}
|
|
26
50
|
export interface InputRequest {
|
|
27
51
|
input?: string;
|
|
28
|
-
/** Current UI view: "chat" or "terminal".
|
|
52
|
+
/** Current UI view: "chat" or "terminal". Chat view uses PTY-derived structured messages. */
|
|
29
53
|
view?: "chat" | "terminal";
|
|
54
|
+
autonomyPolicy?: AutonomyPolicy;
|
|
55
|
+
approvalPolicy?: ApprovalPolicy;
|
|
56
|
+
allowedScopes?: EscalationScope[];
|
|
57
|
+
turn?: TurnRequest;
|
|
30
58
|
}
|
|
31
59
|
export interface ResizeRequest {
|
|
32
60
|
cols?: number;
|
|
@@ -70,14 +98,17 @@ export interface ToolUseBlock {
|
|
|
70
98
|
export interface ToolResultBlock {
|
|
71
99
|
type: "tool_result";
|
|
72
100
|
tool_use_id: string;
|
|
73
|
-
content: string
|
|
101
|
+
content: string | Array<{
|
|
102
|
+
type: string;
|
|
103
|
+
[key: string]: unknown;
|
|
104
|
+
}>;
|
|
74
105
|
is_error?: boolean;
|
|
75
106
|
}
|
|
76
107
|
export type ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock;
|
|
77
108
|
export interface ConversationTurn {
|
|
78
109
|
role: "user" | "assistant";
|
|
79
110
|
content: ContentBlock[];
|
|
80
|
-
/**
|
|
111
|
+
/** Optional usage metadata when available from the underlying tool. */
|
|
81
112
|
usage?: {
|
|
82
113
|
inputTokens?: number;
|
|
83
114
|
outputTokens?: number;
|
|
@@ -91,6 +122,9 @@ export interface SessionSnapshot {
|
|
|
91
122
|
command: string;
|
|
92
123
|
cwd: string;
|
|
93
124
|
mode: ExecutionMode;
|
|
125
|
+
autonomyPolicy?: AutonomyPolicy;
|
|
126
|
+
approvalPolicy?: ApprovalPolicy;
|
|
127
|
+
allowedScopes?: EscalationScope[];
|
|
94
128
|
status: "running" | "exited" | "failed" | "stopped";
|
|
95
129
|
exitCode: number | null;
|
|
96
130
|
startedAt: string;
|
|
@@ -98,8 +132,80 @@ export interface SessionSnapshot {
|
|
|
98
132
|
output: string;
|
|
99
133
|
archived: boolean;
|
|
100
134
|
archivedAt: string | null;
|
|
135
|
+
/** Backward-compatible derived flag from pendingEscalation */
|
|
136
|
+
permissionBlocked?: boolean;
|
|
137
|
+
pendingEscalation?: EscalationRequest | null;
|
|
138
|
+
lastEscalationResult?: {
|
|
139
|
+
requestId: string;
|
|
140
|
+
resolution: EscalationResolution;
|
|
141
|
+
reason: string;
|
|
142
|
+
} | null;
|
|
101
143
|
/** Claude Code 会话 ID,用于 --resume 恢复会话 */
|
|
102
144
|
claudeSessionId: string | null;
|
|
103
|
-
/** Structured conversation messages
|
|
145
|
+
/** Structured conversation messages derived from PTY output. */
|
|
104
146
|
messages?: ConversationTurn[];
|
|
147
|
+
/** Session lifecycle state */
|
|
148
|
+
lifecycleState?: "running" | "idle" | "archived";
|
|
149
|
+
/** Last activity timestamp */
|
|
150
|
+
lastActivityAt?: string | null;
|
|
151
|
+
/** 此会话是从哪个 Wand 会话恢复而来 */
|
|
152
|
+
resumedFromSessionId?: string | null;
|
|
153
|
+
/** 此会话被哪个恢复后的会话替代 */
|
|
154
|
+
resumedToSessionId?: string | null;
|
|
155
|
+
/** 服务器重启时是否自动恢复 */
|
|
156
|
+
autoRecovered?: boolean;
|
|
157
|
+
}
|
|
158
|
+
export type SessionLifecycleState = "initializing" | "running" | "idle" | "thinking" | "waiting-input" | "archived";
|
|
159
|
+
export interface SessionLifecycle {
|
|
160
|
+
state: SessionLifecycleState;
|
|
161
|
+
stateSince: number;
|
|
162
|
+
lastActivityAt: number;
|
|
163
|
+
archivedBy?: "user" | "timeout" | "error";
|
|
164
|
+
archiveReason?: string;
|
|
165
|
+
}
|
|
166
|
+
/** Unified event type emitted by ClaudePtyBridge for WebSocket broadcast */
|
|
167
|
+
export type SessionEventType = "output.raw" | "output.chat" | "chat.turn" | "permission.prompt" | "permission.resolved" | "session.id" | "task" | "ended";
|
|
168
|
+
export interface SessionEvent {
|
|
169
|
+
type: SessionEventType;
|
|
170
|
+
sessionId: string;
|
|
171
|
+
timestamp: number;
|
|
172
|
+
data?: unknown;
|
|
173
|
+
}
|
|
174
|
+
export interface RawOutputData {
|
|
175
|
+
chunk: string;
|
|
176
|
+
/** Full accumulated output for terminal view */
|
|
177
|
+
output: string;
|
|
178
|
+
}
|
|
179
|
+
export interface ChatOutputData {
|
|
180
|
+
/** Current messages array */
|
|
181
|
+
messages: ConversationTurn[];
|
|
182
|
+
/** Index of the message being streamed */
|
|
183
|
+
streamingIndex?: number;
|
|
184
|
+
/** Whether assistant is currently responding */
|
|
185
|
+
isResponding: boolean;
|
|
186
|
+
}
|
|
187
|
+
export interface ChatTurnData {
|
|
188
|
+
/** The completed turn */
|
|
189
|
+
turn: ConversationTurn;
|
|
190
|
+
/** Full messages array */
|
|
191
|
+
messages: ConversationTurn[];
|
|
192
|
+
}
|
|
193
|
+
export interface PermissionPromptData {
|
|
194
|
+
/** Detected prompt text */
|
|
195
|
+
prompt: string;
|
|
196
|
+
/** Inferred scope */
|
|
197
|
+
scope: EscalationScope;
|
|
198
|
+
/** Target if detected */
|
|
199
|
+
target?: string;
|
|
200
|
+
}
|
|
201
|
+
export interface SessionIdData {
|
|
202
|
+
/** Claude CLI session UUID */
|
|
203
|
+
claudeSessionId: string;
|
|
204
|
+
}
|
|
205
|
+
export interface TaskData {
|
|
206
|
+
title: string;
|
|
207
|
+
tool?: string;
|
|
208
|
+
}
|
|
209
|
+
export interface SessionEndData {
|
|
210
|
+
exitCode: number | null;
|
|
105
211
|
}
|
|
Binary file
|