claude-multi-session 1.0.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,170 @@
1
+ /**
2
+ * SafetyNet — Automatic limits and protection for automated sessions.
3
+ *
4
+ * Enforces:
5
+ * - Cost limit — Kill session if cumulative cost exceeds threshold
6
+ * - Turn limit — Kill session if total agent turns exceed threshold
7
+ * - Time limit — Kill session if a single interaction takes too long
8
+ * - File protection — Prevent modifications to critical files/paths
9
+ *
10
+ * Integrates with StreamSession via the 'result' event.
11
+ *
12
+ * Usage:
13
+ * const safety = new SafetyNet({
14
+ * maxCostUsd: 1.00, // Kill if total cost exceeds $1
15
+ * maxTurns: 30, // Kill if total turns exceed 30
16
+ * maxDurationMs: 300000, // Kill if single interaction > 5 min
17
+ * protectedPaths: ['.env', 'node_modules', '.git'],
18
+ * });
19
+ * safety.attach(streamSession);
20
+ */
21
+
22
+ const { EventEmitter } = require('events');
23
+
24
+ // Default safety limits
25
+ const DEFAULTS = {
26
+ maxCostUsd: 2.00, // $2 per session
27
+ maxTurns: 50, // 50 agent turns
28
+ maxDurationMs: 300000, // 5 minutes per interaction
29
+ protectedPaths: [ // Files that should never be modified
30
+ '.env',
31
+ '.env.local',
32
+ '.env.production',
33
+ 'credentials',
34
+ 'secrets',
35
+ '.git/',
36
+ 'node_modules/',
37
+ ],
38
+ };
39
+
40
+ class SafetyNet extends EventEmitter {
41
+ /**
42
+ * @param {object} [limits] - Override default limits
43
+ * @param {number} [limits.maxCostUsd] - Max total cost in USD
44
+ * @param {number} [limits.maxTurns] - Max total agent turns
45
+ * @param {number} [limits.maxDurationMs] - Max time per interaction
46
+ * @param {string[]} [limits.protectedPaths] - Paths that can't be modified
47
+ */
48
+ constructor(limits = {}) {
49
+ super();
50
+ this.maxCostUsd = limits.maxCostUsd ?? DEFAULTS.maxCostUsd;
51
+ this.maxTurns = limits.maxTurns ?? DEFAULTS.maxTurns;
52
+ this.maxDurationMs = limits.maxDurationMs ?? DEFAULTS.maxDurationMs;
53
+ this.protectedPaths = limits.protectedPaths || DEFAULTS.protectedPaths;
54
+
55
+ this.violations = []; // Record of all limit violations
56
+ this.attached = null; // The session we're monitoring
57
+ }
58
+
59
+ /**
60
+ * Attach to a StreamSession to monitor it.
61
+ * @param {StreamSession} session
62
+ */
63
+ attach(session) {
64
+ this.attached = session;
65
+
66
+ // Check limits after each result
67
+ session.on('result', (response) => {
68
+ this._checkCost(session, response);
69
+ this._checkTurns(session, response);
70
+ });
71
+
72
+ // Check for protected file access in tool calls
73
+ session.on('tool-use', (toolCall) => {
74
+ this._checkProtectedPaths(session, toolCall);
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Check if cost limit is exceeded.
80
+ */
81
+ _checkCost(session, response) {
82
+ if (this.maxCostUsd && session.totalCostUsd > this.maxCostUsd) {
83
+ const violation = {
84
+ type: 'cost_exceeded',
85
+ limit: this.maxCostUsd,
86
+ actual: session.totalCostUsd,
87
+ message: `Cost limit exceeded: $${session.totalCostUsd.toFixed(4)} > $${this.maxCostUsd.toFixed(2)} limit`,
88
+ timestamp: new Date().toISOString(),
89
+ };
90
+ this.violations.push(violation);
91
+ this.emit('violation', violation);
92
+ this.emit('kill', violation);
93
+
94
+ // Force kill the session
95
+ session.kill();
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Check if turn limit is exceeded.
101
+ */
102
+ _checkTurns(session, response) {
103
+ if (this.maxTurns && session.totalTurns > this.maxTurns) {
104
+ const violation = {
105
+ type: 'turns_exceeded',
106
+ limit: this.maxTurns,
107
+ actual: session.totalTurns,
108
+ message: `Turn limit exceeded: ${session.totalTurns} > ${this.maxTurns} limit`,
109
+ timestamp: new Date().toISOString(),
110
+ };
111
+ this.violations.push(violation);
112
+ this.emit('violation', violation);
113
+ this.emit('kill', violation);
114
+
115
+ session.kill();
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Check if a tool call targets a protected path.
121
+ */
122
+ _checkProtectedPaths(session, toolCall) {
123
+ if (!toolCall.input) return;
124
+
125
+ // Check common file path fields in tool inputs
126
+ const pathFields = ['file_path', 'path', 'command'];
127
+ for (const field of pathFields) {
128
+ const value = toolCall.input[field];
129
+ if (typeof value !== 'string') continue;
130
+
131
+ for (const protected_path of this.protectedPaths) {
132
+ if (value.includes(protected_path)) {
133
+ // Only flag write operations
134
+ const writeTools = ['Write', 'Edit', 'NotebookEdit', 'Bash'];
135
+ if (writeTools.includes(toolCall.name)) {
136
+ const violation = {
137
+ type: 'protected_path',
138
+ tool: toolCall.name,
139
+ path: value,
140
+ protectedPattern: protected_path,
141
+ message: `Attempted to modify protected path: ${value} (matches "${protected_path}")`,
142
+ timestamp: new Date().toISOString(),
143
+ };
144
+ this.violations.push(violation);
145
+ this.emit('violation', violation);
146
+ // Don't kill — just warn. The permission system handles blocking.
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Get a summary of all violations.
155
+ */
156
+ getSummary() {
157
+ return {
158
+ totalViolations: this.violations.length,
159
+ violations: this.violations,
160
+ limits: {
161
+ maxCostUsd: this.maxCostUsd,
162
+ maxTurns: this.maxTurns,
163
+ maxDurationMs: this.maxDurationMs,
164
+ protectedPaths: this.protectedPaths,
165
+ },
166
+ };
167
+ }
168
+ }
169
+
170
+ module.exports = SafetyNet;
package/src/store.js ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Store — JSON file persistence for session metadata.
3
+ *
4
+ * Sessions are kept alive in-memory (as StreamSession objects) by the Manager,
5
+ * but their metadata is persisted here so sessions can survive process restarts.
6
+ *
7
+ * The store saves:
8
+ * - Session config (name, model, workDir, etc.)
9
+ * - Claude session ID (for resuming after restart)
10
+ * - Interaction history (prompts + responses)
11
+ * - Cost tracking
12
+ *
13
+ * Data location: ~/.claude-multi-session/ (user home directory)
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const os = require('os');
19
+
20
+ // Default data directory in user's home
21
+ const DEFAULT_DATA_DIR = path.join(os.homedir(), '.claude-multi-session');
22
+
23
+ class Store {
24
+ /**
25
+ * @param {string} [dataDir] - Override data directory path
26
+ */
27
+ constructor(dataDir) {
28
+ this.dataDir = dataDir || DEFAULT_DATA_DIR;
29
+ this.sessionsFile = path.join(this.dataDir, 'sessions.json');
30
+
31
+ // Ensure directory exists
32
+ if (!fs.existsSync(this.dataDir)) {
33
+ fs.mkdirSync(this.dataDir, { recursive: true });
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Load all session records from disk.
39
+ * @returns {Object<string, object>} Map of name -> session data
40
+ */
41
+ loadAll() {
42
+ if (!fs.existsSync(this.sessionsFile)) {
43
+ return {};
44
+ }
45
+ try {
46
+ return JSON.parse(fs.readFileSync(this.sessionsFile, 'utf-8'));
47
+ } catch (err) {
48
+ return {};
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Save all session records to disk.
54
+ * @param {Object<string, object>} sessions
55
+ */
56
+ saveAll(sessions) {
57
+ fs.writeFileSync(this.sessionsFile, JSON.stringify(sessions, null, 2), 'utf-8');
58
+ }
59
+
60
+ /**
61
+ * Get a single session record.
62
+ * @param {string} name
63
+ * @returns {object|null}
64
+ */
65
+ get(name) {
66
+ const all = this.loadAll();
67
+ return all[name] || null;
68
+ }
69
+
70
+ /**
71
+ * Save/update a single session record.
72
+ * @param {string} name
73
+ * @param {object} data
74
+ */
75
+ set(name, data) {
76
+ const all = this.loadAll();
77
+ all[name] = { ...data, lastSaved: new Date().toISOString() };
78
+ this.saveAll(all);
79
+ }
80
+
81
+ /**
82
+ * Delete a session record.
83
+ * @param {string} name
84
+ */
85
+ delete(name) {
86
+ const all = this.loadAll();
87
+ delete all[name];
88
+ this.saveAll(all);
89
+ }
90
+
91
+ /**
92
+ * List all session records.
93
+ * @param {string} [statusFilter] - Optional status filter
94
+ * @returns {object[]}
95
+ */
96
+ list(statusFilter) {
97
+ const all = this.loadAll();
98
+ let list = Object.values(all);
99
+ if (statusFilter) {
100
+ list = list.filter(s => s.status === statusFilter);
101
+ }
102
+ return list;
103
+ }
104
+
105
+ /**
106
+ * Clean up old sessions (completed/failed/killed older than N days).
107
+ * @param {number} days
108
+ * @returns {string[]} Names of removed sessions
109
+ */
110
+ cleanup(days = 7) {
111
+ const all = this.loadAll();
112
+ const cutoff = new Date();
113
+ cutoff.setDate(cutoff.getDate() - days);
114
+ const cutoffISO = cutoff.toISOString();
115
+ const removed = [];
116
+
117
+ for (const [name, session] of Object.entries(all)) {
118
+ const lastActive = session.lastSaved || session.created;
119
+ if (lastActive < cutoffISO && ['completed', 'failed', 'killed', 'stopped'].includes(session.status)) {
120
+ removed.push(name);
121
+ delete all[name];
122
+ }
123
+ }
124
+
125
+ this.saveAll(all);
126
+ return removed;
127
+ }
128
+ }
129
+
130
+ module.exports = Store;