@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.
@@ -150,19 +150,24 @@ export class SessionLifecycleManager {
150
150
  checkSessions() {
151
151
  const now = Date.now();
152
152
  for (const [sessionId, lifecycle] of this.sessions) {
153
- if (lifecycle.state === "archived") {
154
- continue;
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
+ }
155
168
  }
156
- const timeSinceLastActivity = now - lifecycle.lastActivityAt;
157
- // Check for archive timeout
158
- if (timeSinceLastActivity > this.archiveTimeout) {
159
- this.archive(sessionId, "Session timed out", "timeout");
160
- continue;
161
- }
162
- // Check for idle timeout
163
- if (timeSinceLastActivity > this.idleTimeout && lifecycle.state !== "idle") {
164
- this.setState(sessionId, "idle");
165
- this.events.onIdle?.(sessionId);
169
+ catch (err) {
170
+ console.error(`[Lifecycle] Error checking session ${sessionId}: ${String(err)}`);
166
171
  }
167
172
  }
168
173
  }
@@ -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 Raw PTY output (append-only)
7
- * - stream-events.jsonl NDJSON events from native mode (append-only)
8
- * - messages.json Final structured messages (overwritten on each update)
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
  }
@@ -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 Raw PTY output (append-only)
9
- * - stream-events.jsonl NDJSON events from native mode (append-only)
10
- * - messages.json Final structured messages (overwritten on each update)
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
- appendFileSync(path.join(dir, "pty-output.log"), chunk);
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
@@ -124,9 +124,10 @@ export class WandStorage {
124
124
  try {
125
125
  this.db
126
126
  .prepare(`INSERT INTO command_sessions (
127
- id, command, cwd, mode, status, exit_code, started_at, ended_at, output
127
+ id, command, cwd, mode, status, exit_code, started_at, ended_at, output
128
128
  , archived, archived_at, claude_session_id, messages
129
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
129
+ , resumed_from_session_id, resumed_to_session_id, auto_recovered
130
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
130
131
  ON CONFLICT(id) DO UPDATE SET
131
132
  command = excluded.command,
132
133
  cwd = excluded.cwd,
@@ -139,8 +140,11 @@ export class WandStorage {
139
140
  archived = excluded.archived,
140
141
  archived_at = excluded.archived_at,
141
142
  claude_session_id = excluded.claude_session_id,
142
- messages = excluded.messages`)
143
- .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);
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);
144
148
  this.db.exec("COMMIT");
145
149
  }
146
150
  catch (error) {
@@ -148,13 +152,52 @@ export class WandStorage {
148
152
  throw error;
149
153
  }
150
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
+ }
151
190
  loadSessions() {
152
191
  const rows = this.db
153
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
154
194
  FROM command_sessions
155
195
  ORDER BY started_at DESC`)
156
196
  .all();
157
- return rows.map((row) => ({
197
+ return rows.map((row) => this.mapSessionRow(row));
198
+ }
199
+ mapSessionRow(row) {
200
+ return {
158
201
  id: row.id,
159
202
  command: row.command,
160
203
  cwd: row.cwd,
@@ -167,8 +210,11 @@ export class WandStorage {
167
210
  archived: Boolean(row.archived),
168
211
  archivedAt: row.archived_at,
169
212
  claudeSessionId: row.claude_session_id,
170
- messages: parseStoredMessages(row.messages)
171
- }));
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
+ };
172
218
  }
173
219
  deleteSession(id) {
174
220
  this.db.prepare("DELETE FROM command_sessions WHERE id = ?").run(id);
@@ -189,4 +235,13 @@ function ensureCommandSessionSchema(db) {
189
235
  if (!names.has("messages")) {
190
236
  db.exec("ALTER TABLE command_sessions ADD COLUMN messages TEXT");
191
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
+ }
192
247
  }
package/dist/types.d.ts CHANGED
@@ -31,7 +31,7 @@ export interface CommandPreset {
31
31
  export interface WandConfig {
32
32
  host: string;
33
33
  port: number;
34
- /** Enable HTTPS with self-signed certificate (default: true) */
34
+ /** Enable HTTPS with self-signed certificate (default: false) */
35
35
  https?: boolean;
36
36
  password: string;
37
37
  defaultMode: ExecutionMode;
@@ -147,7 +147,13 @@ export interface SessionSnapshot {
147
147
  /** Session lifecycle state */
148
148
  lifecycleState?: "running" | "idle" | "archived";
149
149
  /** Last activity timestamp */
150
- lastActivityAt?: string;
150
+ lastActivityAt?: string | null;
151
+ /** 此会话是从哪个 Wand 会话恢复而来 */
152
+ resumedFromSessionId?: string | null;
153
+ /** 此会话被哪个恢复后的会话替代 */
154
+ resumedToSessionId?: string | null;
155
+ /** 服务器重启时是否自动恢复 */
156
+ autoRecovered?: boolean;
151
157
  }
152
158
  export type SessionLifecycleState = "initializing" | "running" | "idle" | "thinking" | "waiting-input" | "archived";
153
159
  export interface SessionLifecycle {
Binary file
Binary file