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.
- package/LICENSE +21 -0
- package/README.md +545 -0
- package/STRATEGY.md +179 -0
- package/bin/cli.js +693 -0
- package/bin/mcp.js +27 -0
- package/bin/setup.js +469 -0
- package/package.json +42 -0
- package/src/delegate.js +343 -0
- package/src/index.js +35 -0
- package/src/manager.js +510 -0
- package/src/mcp-server.js +808 -0
- package/src/safety-net.js +170 -0
- package/src/store.js +130 -0
- package/src/stream-session.js +463 -0
|
@@ -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;
|