@co0ontty/wand 0.2.0 → 0.3.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.
@@ -0,0 +1,176 @@
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
+ if (lifecycle.state === "archived") {
154
+ continue;
155
+ }
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);
166
+ }
167
+ }
168
+ }
169
+ /**
170
+ * Cleanup all sessions
171
+ */
172
+ cleanup() {
173
+ this.stopPeriodicCheck();
174
+ this.sessions.clear();
175
+ }
176
+ }
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);
@@ -156,7 +167,7 @@ export class WandStorage {
156
167
  archived: Boolean(row.archived),
157
168
  archivedAt: row.archived_at,
158
169
  claudeSessionId: row.claude_session_id,
159
- messages: row.messages ? JSON.parse(row.messages) : undefined
170
+ messages: parseStoredMessages(row.messages)
160
171
  }));
161
172
  }
162
173
  deleteSession(id) {
package/dist/types.d.ts CHANGED
@@ -1,4 +1,28 @@
1
- export type ExecutionMode = "auto-edit" | "default" | "full-access" | "native" | "managed";
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;
@@ -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". Used to route via native pipeline in chat mode. */
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
- /** Token usage for this turn (native mode only) */
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,74 @@ 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 (from JSON chat mode) */
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;
151
+ }
152
+ export type SessionLifecycleState = "initializing" | "running" | "idle" | "thinking" | "waiting-input" | "archived";
153
+ export interface SessionLifecycle {
154
+ state: SessionLifecycleState;
155
+ stateSince: number;
156
+ lastActivityAt: number;
157
+ archivedBy?: "user" | "timeout" | "error";
158
+ archiveReason?: string;
159
+ }
160
+ /** Unified event type emitted by ClaudePtyBridge for WebSocket broadcast */
161
+ export type SessionEventType = "output.raw" | "output.chat" | "chat.turn" | "permission.prompt" | "permission.resolved" | "session.id" | "task" | "ended";
162
+ export interface SessionEvent {
163
+ type: SessionEventType;
164
+ sessionId: string;
165
+ timestamp: number;
166
+ data?: unknown;
167
+ }
168
+ export interface RawOutputData {
169
+ chunk: string;
170
+ /** Full accumulated output for terminal view */
171
+ output: string;
172
+ }
173
+ export interface ChatOutputData {
174
+ /** Current messages array */
175
+ messages: ConversationTurn[];
176
+ /** Index of the message being streamed */
177
+ streamingIndex?: number;
178
+ /** Whether assistant is currently responding */
179
+ isResponding: boolean;
180
+ }
181
+ export interface ChatTurnData {
182
+ /** The completed turn */
183
+ turn: ConversationTurn;
184
+ /** Full messages array */
185
+ messages: ConversationTurn[];
186
+ }
187
+ export interface PermissionPromptData {
188
+ /** Detected prompt text */
189
+ prompt: string;
190
+ /** Inferred scope */
191
+ scope: EscalationScope;
192
+ /** Target if detected */
193
+ target?: string;
194
+ }
195
+ export interface SessionIdData {
196
+ /** Claude CLI session UUID */
197
+ claudeSessionId: string;
198
+ }
199
+ export interface TaskData {
200
+ title: string;
201
+ tool?: string;
202
+ }
203
+ export interface SessionEndData {
204
+ exitCode: number | null;
105
205
  }