scalix 1.0.0 → 1.1.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/dist/sessions.js CHANGED
@@ -5,8 +5,34 @@ import { randomUUID } from 'node:crypto';
5
5
  import { logger } from './logger.js';
6
6
  import { USER_CONFIG_DIR } from './constants.js';
7
7
  const SESSIONS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '.', USER_CONFIG_DIR, 'sessions');
8
+ let cleanupDone = false;
9
+ async function cleanupStaleTempFiles() {
10
+ if (cleanupDone)
11
+ return;
12
+ cleanupDone = true;
13
+ try {
14
+ const tmpDir = os.tmpdir();
15
+ const entries = await fs.readdir(tmpDir);
16
+ const oneHourAgo = Date.now() - 3600_000;
17
+ for (const entry of entries) {
18
+ if (!entry.startsWith('scalix-session-') || !entry.endsWith('.tmp'))
19
+ continue;
20
+ try {
21
+ const fullPath = path.join(tmpDir, entry);
22
+ const stat = await fs.stat(fullPath);
23
+ if (stat.mtimeMs < oneHourAgo) {
24
+ await fs.unlink(fullPath);
25
+ logger.debug('Cleaned up stale temp file', { file: entry });
26
+ }
27
+ }
28
+ catch { }
29
+ }
30
+ }
31
+ catch { }
32
+ }
8
33
  async function ensureDir() {
9
34
  await fs.mkdir(SESSIONS_DIR, { recursive: true });
35
+ await cleanupStaleTempFiles();
10
36
  }
11
37
  function sessionPath(id) {
12
38
  return path.join(SESSIONS_DIR, `${id}.json`);
@@ -32,12 +58,19 @@ export async function saveSession(id, messages, model, name, costState) {
32
58
  }
33
59
  export async function loadSession(id) {
34
60
  const raw = await fs.readFile(sessionPath(id), 'utf-8');
61
+ let data;
35
62
  try {
36
- return JSON.parse(raw);
63
+ data = JSON.parse(raw);
37
64
  }
38
65
  catch {
39
66
  throw new Error(`Corrupted session file: ${id}`);
40
67
  }
68
+ if (!data || typeof data !== 'object' ||
69
+ !('meta' in data) || !('messages' in data) ||
70
+ !Array.isArray(data.messages)) {
71
+ throw new Error(`Invalid session structure: ${id}`);
72
+ }
73
+ return data;
41
74
  }
42
75
  export async function listSessions() {
43
76
  await ensureDir();
@@ -49,7 +82,9 @@ export async function listSessions() {
49
82
  try {
50
83
  const raw = await fs.readFile(path.join(SESSIONS_DIR, file), 'utf-8');
51
84
  const data = JSON.parse(raw);
52
- sessions.push(data.meta);
85
+ if (data?.meta?.id && data?.meta?.updatedAt) {
86
+ sessions.push(data.meta);
87
+ }
53
88
  }
54
89
  catch (e) {
55
90
  logger.warn('Failed to read session file', { file, error: e.message });
package/dist/settings.js CHANGED
@@ -1,9 +1,15 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { DEFAULT_BASE_URL, DEFAULT_MODELS, AGENT_DEFAULTS, CONTEXT_DEFAULTS, WEB_DEFAULTS, TOOL_DEFAULTS, SUB_AGENT_DEFAULTS, USER_CONFIG_DIR, registerUserModels, } from './constants.js';
4
- const CONFIG_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '.', USER_CONFIG_DIR);
5
- const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
3
+ import { setSecurityConfig } from './sandbox.js';
4
+ import { DEFAULT_BASE_URL, DEFAULT_MODELS, AGENT_DEFAULTS, CONTEXT_DEFAULTS, WEB_DEFAULTS, TOOL_DEFAULTS, SUB_AGENT_DEFAULTS, MULTI_AGENT_DEFAULTS, USER_CONFIG_DIR, registerUserModels, setNetworkSecurityOverrides, } from './constants.js';
5
+ function getConfigDir() {
6
+ return path.join(process.env.HOME || process.env.USERPROFILE || '.', USER_CONFIG_DIR);
7
+ }
8
+ function getConfigFile() {
9
+ return path.join(getConfigDir(), 'config.json');
10
+ }
6
11
  export const THINKING_MODES = ['off', 'low', 'medium', 'high'];
12
+ export const DEFAULT_SECURITY = {};
7
13
  export const DEFAULT_LIMITS = {
8
14
  maxSteps: AGENT_DEFAULTS.maxSteps,
9
15
  maxMessages: AGENT_DEFAULTS.maxMessages,
@@ -41,6 +47,9 @@ export const DEFAULT_LIMITS = {
41
47
  maxNotebookSource: TOOL_DEFAULTS.maxNotebookSource,
42
48
  maxWebFetchLength: TOOL_DEFAULTS.maxWebFetchLength,
43
49
  slidingWindowTarget: CONTEXT_DEFAULTS.slidingWindowTarget,
50
+ maxConcurrentAgents: MULTI_AGENT_DEFAULTS.maxConcurrentAgents,
51
+ maxAgentDepth: MULTI_AGENT_DEFAULTS.maxDepth,
52
+ globalTokenBudget: MULTI_AGENT_DEFAULTS.globalTokenBudget,
44
53
  };
45
54
  const DEFAULTS = {
46
55
  apiKey: '',
@@ -50,6 +59,9 @@ const DEFAULTS = {
50
59
  limits: { ...DEFAULT_LIMITS },
51
60
  permissionMode: 'default',
52
61
  thinkingMode: 'off',
62
+ security: { ...DEFAULT_SECURITY },
63
+ injectionDetection: { enabled: true, sensitivity: 'medium' },
64
+ disableUpdateCheck: false,
53
65
  };
54
66
  function safeNumber(val, min, max, fallback) {
55
67
  if (typeof val === 'number' && Number.isFinite(val) && val >= min && val <= max)
@@ -121,25 +133,118 @@ function validateConfig(parsed) {
121
133
  maxNotebookSource: safeNumber(l.maxNotebookSource, 1000, Infinity, DEFAULT_LIMITS.maxNotebookSource),
122
134
  maxWebFetchLength: safeNumber(l.maxWebFetchLength, 1000, Infinity, DEFAULT_LIMITS.maxWebFetchLength),
123
135
  slidingWindowTarget: safeNumber(l.slidingWindowTarget, 0.1, 0.99, DEFAULT_LIMITS.slidingWindowTarget),
136
+ maxConcurrentAgents: safeNumber(l.maxConcurrentAgents, 1, 100, DEFAULT_LIMITS.maxConcurrentAgents),
137
+ maxAgentDepth: safeNumber(l.maxAgentDepth, 1, 20, DEFAULT_LIMITS.maxAgentDepth),
138
+ globalTokenBudget: safeNumber(l.globalTokenBudget, 10_000, Infinity, DEFAULT_LIMITS.globalTokenBudget),
124
139
  };
125
140
  }
126
141
  if (typeof parsed.thinkingMode === 'string' && THINKING_MODES.includes(parsed.thinkingMode)) {
127
142
  safe.thinkingMode = parsed.thinkingMode;
128
143
  }
144
+ if (parsed.security && typeof parsed.security === 'object' && !Array.isArray(parsed.security)) {
145
+ safe.security = validateSecurityConfig(parsed.security);
146
+ }
147
+ if (parsed.injectionDetection && typeof parsed.injectionDetection === 'object') {
148
+ const id = parsed.injectionDetection;
149
+ const config = {
150
+ enabled: typeof id.enabled === 'boolean' ? id.enabled : true,
151
+ sensitivity: ['low', 'medium', 'high'].includes(id.sensitivity) ? id.sensitivity : 'medium',
152
+ };
153
+ if (isStringArray(id.customPatterns)) {
154
+ config.customPatterns = validateRegexStrings(id.customPatterns);
155
+ }
156
+ safe.injectionDetection = config;
157
+ }
158
+ if (typeof parsed.disableUpdateCheck === 'boolean') {
159
+ safe.disableUpdateCheck = parsed.disableUpdateCheck;
160
+ }
129
161
  return safe;
130
162
  }
163
+ function isStringArray(val) {
164
+ return Array.isArray(val) && val.every((v) => typeof v === 'string');
165
+ }
166
+ function validateRegexStrings(patterns) {
167
+ return patterns.filter(p => {
168
+ try {
169
+ new RegExp(p);
170
+ return true;
171
+ }
172
+ catch {
173
+ return false;
174
+ }
175
+ });
176
+ }
177
+ function validateSecurityConfig(raw) {
178
+ const sec = {};
179
+ if (isStringArray(raw.additionalBlockedCommands)) {
180
+ sec.additionalBlockedCommands = validateRegexStrings(raw.additionalBlockedCommands);
181
+ }
182
+ if (isStringArray(raw.disabledBlockedCommands)) {
183
+ sec.disabledBlockedCommands = raw.disabledBlockedCommands;
184
+ }
185
+ if (isStringArray(raw.additionalInjectionPatterns)) {
186
+ sec.additionalInjectionPatterns = validateRegexStrings(raw.additionalInjectionPatterns);
187
+ }
188
+ if (isStringArray(raw.disabledInjectionPatterns)) {
189
+ sec.disabledInjectionPatterns = raw.disabledInjectionPatterns;
190
+ }
191
+ if (isStringArray(raw.additionalReadOnlyTools))
192
+ sec.additionalReadOnlyTools = raw.additionalReadOnlyTools;
193
+ if (isStringArray(raw.additionalWriteTools))
194
+ sec.additionalWriteTools = raw.additionalWriteTools;
195
+ if (isStringArray(raw.additionalDangerousTools))
196
+ sec.additionalDangerousTools = raw.additionalDangerousTools;
197
+ if (raw.toolOverrides && typeof raw.toolOverrides === 'object' && !Array.isArray(raw.toolOverrides)) {
198
+ const valid = {};
199
+ const levels = new Set(['read-only', 'write', 'dangerous']);
200
+ for (const [tool, level] of Object.entries(raw.toolOverrides)) {
201
+ if (typeof level === 'string' && levels.has(level)) {
202
+ valid[tool] = level;
203
+ }
204
+ }
205
+ if (Object.keys(valid).length > 0)
206
+ sec.toolOverrides = valid;
207
+ }
208
+ if (typeof raw.allowPrivateNetworkAccess === 'boolean')
209
+ sec.allowPrivateNetworkAccess = raw.allowPrivateNetworkAccess;
210
+ if (isStringArray(raw.additionalBlockedHosts))
211
+ sec.additionalBlockedHosts = raw.additionalBlockedHosts;
212
+ if (isStringArray(raw.allowedPrivateHosts))
213
+ sec.allowedPrivateHosts = raw.allowedPrivateHosts;
214
+ if (isStringArray(raw.additionalSensitiveEnvPatterns)) {
215
+ sec.additionalSensitiveEnvPatterns = validateRegexStrings(raw.additionalSensitiveEnvPatterns);
216
+ }
217
+ if (isStringArray(raw.allowedEnvPatterns)) {
218
+ sec.allowedEnvPatterns = validateRegexStrings(raw.allowedEnvPatterns);
219
+ }
220
+ if (typeof raw.maxImageSize === 'number' && raw.maxImageSize > 0) {
221
+ sec.maxImageSize = raw.maxImageSize;
222
+ }
223
+ return sec;
224
+ }
131
225
  async function ensureDir() {
132
- await fs.mkdir(CONFIG_DIR, { recursive: true });
226
+ await fs.mkdir(getConfigDir(), { recursive: true });
227
+ }
228
+ export function applySecurityConfig(security) {
229
+ setSecurityConfig(security);
230
+ setNetworkSecurityOverrides({
231
+ allowAll: security.allowPrivateNetworkAccess,
232
+ blockedHosts: security.additionalBlockedHosts,
233
+ allowedHosts: security.allowedPrivateHosts,
234
+ });
133
235
  }
134
236
  export async function loadConfig() {
135
237
  try {
136
- const raw = await fs.readFile(CONFIG_FILE, 'utf-8');
238
+ const raw = await fs.readFile(getConfigFile(), 'utf-8');
137
239
  const parsed = JSON.parse(raw);
138
240
  const validated = validateConfig(parsed);
139
241
  const config = { ...DEFAULTS, ...validated };
140
242
  if (config.customModels && Object.keys(config.customModels).length > 0) {
141
243
  registerUserModels(config.customModels);
142
244
  }
245
+ if (config.security && Object.keys(config.security).length > 0) {
246
+ applySecurityConfig(config.security);
247
+ }
143
248
  return config;
144
249
  }
145
250
  catch {
@@ -148,7 +253,7 @@ export async function loadConfig() {
148
253
  }
149
254
  export async function saveConfig(config) {
150
255
  await ensureDir();
151
- await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), { encoding: 'utf-8', mode: 0o600 });
256
+ await fs.writeFile(getConfigFile(), JSON.stringify(config, null, 2), { encoding: 'utf-8', mode: 0o600 });
152
257
  }
153
258
  export function isConfigured(config) {
154
259
  return config.apiKey.length > 0 && config.models.length > 0;
package/dist/sub-agent.js CHANGED
@@ -5,15 +5,21 @@ import { webTools } from './tools-web.js';
5
5
  import { advancedTools } from './tools-advanced.js';
6
6
  import { logger } from './logger.js';
7
7
  import { SUB_AGENT_DEFAULTS, CHEAP_MODEL_INDICATORS } from './constants.js';
8
+ import { AgentRegistry, generateAgentId } from './agent-registry.js';
9
+ import { createWorktree, removeWorktree, mergeWorktree } from './worktree.js';
8
10
  const READ_ONLY_TOOL_NAMES = new Set([
9
11
  'get_current_time', 'calculate', 'read_file', 'read_file_lines',
10
12
  'list_files', 'search_files', 'find_files', 'tree',
11
13
  'git_status', 'git_diff', 'web_search', 'monitor',
12
14
  ]);
13
- function selectTools(level) {
14
- // Include advanced tools but exclude 'agent' to prevent recursive spawning
15
+ function selectTools(level, allowAgentTool = false) {
15
16
  const all = [...defaultTools, ...extraTools, ...webTools, ...advancedTools]
16
- .filter((t) => (t.function?.name || t.name) !== 'agent');
17
+ .filter((t) => {
18
+ const name = t.function?.name || t.name;
19
+ if (name === 'agent' && !allowAgentTool)
20
+ return false;
21
+ return true;
22
+ });
17
23
  switch (level) {
18
24
  case 'read-only':
19
25
  return all.filter((t) => READ_ONLY_TOOL_NAMES.has(t.function?.name || t.name));
@@ -26,10 +32,32 @@ function selectTools(level) {
26
32
  }
27
33
  export async function spawnSubAgent(parentConfig, prompt, options = {}) {
28
34
  const model = options.model || parentConfig.models[parentConfig.models.length - 1];
29
- const tools = selectTools(options.tools);
35
+ const allowAgentTool = options.allowAgentTool ?? false;
36
+ const tools = selectTools(options.tools, allowAgentTool);
30
37
  const timeout = options.timeout ?? SUB_AGENT_DEFAULTS.timeoutMs;
38
+ const subAgentId = generateAgentId();
39
+ const parentId = options.parentAgentId ?? null;
40
+ const description = options.agentDescription ?? 'sub-agent';
41
+ const registry = AgentRegistry.getInstance();
42
+ const spawnCheck = registry.canSpawn(parentId);
43
+ if (!spawnCheck.allowed) {
44
+ throw new Error(`Cannot spawn agent: ${spawnCheck.reason}`);
45
+ }
31
46
  const permissionMode = options.permissionMode
32
- ?? (options.tools === 'read-only' ? 'plan' : options.tools === 'all' ? 'auto' : 'default');
47
+ ?? (options.tools === 'read-only' ? 'plan' : parentConfig.permissionMode ?? 'default');
48
+ // Worktree isolation for write-capable agents
49
+ const shouldIsolate = options.isolate !== false && options.tools !== 'read-only';
50
+ let worktreeInfo = null;
51
+ if (shouldIsolate) {
52
+ try {
53
+ worktreeInfo = await createWorktree(subAgentId, process.cwd());
54
+ logger.info('Agent isolated in worktree', { agentId: subAgentId, path: worktreeInfo.path });
55
+ }
56
+ catch (err) {
57
+ logger.warn('Worktree creation failed, running without isolation', { error: err.message });
58
+ }
59
+ }
60
+ const isReadOnly = options.tools === 'read-only';
33
61
  const subAgent = createAgent({
34
62
  apiKey: parentConfig.apiKey,
35
63
  baseUrl: parentConfig.baseUrl,
@@ -43,6 +71,11 @@ export async function spawnSubAgent(parentConfig, prompt, options = {}) {
43
71
  maxContextTokens: SUB_AGENT_DEFAULTS.maxContextTokens,
44
72
  compressionThreshold: SUB_AGENT_DEFAULTS.compressionThreshold,
45
73
  },
74
+ agentId: subAgentId,
75
+ parentAgentId: parentId,
76
+ agentDescription: description,
77
+ groundingCheck: true,
78
+ contextRetrieval: !isReadOnly,
46
79
  });
47
80
  let steps = 0;
48
81
  let tokenEstimate = 0;
@@ -56,18 +89,60 @@ export async function spawnSubAgent(parentConfig, prompt, options = {}) {
56
89
  });
57
90
  try {
58
91
  const response = await Promise.race([subAgent.send(prompt), timeoutPromise]);
59
- logger.info('Sub-agent completed', { model, steps, tokens: tokenEstimate });
60
- return { response, model, tokenEstimate, steps };
92
+ logger.info('Sub-agent completed', { agentId: subAgentId, model, steps, tokens: tokenEstimate });
93
+ let mergeResult;
94
+ if (worktreeInfo) {
95
+ try {
96
+ mergeResult = await mergeWorktree(worktreeInfo, process.cwd());
97
+ logger.info('Worktree merge result', {
98
+ agentId: subAgentId,
99
+ merged: mergeResult.merged,
100
+ commits: mergeResult.commitCount,
101
+ conflicts: mergeResult.conflicts?.length ?? 0,
102
+ });
103
+ }
104
+ catch (err) {
105
+ logger.warn('Worktree merge failed', { error: err.message });
106
+ }
107
+ }
108
+ return {
109
+ response,
110
+ model,
111
+ tokenEstimate,
112
+ steps,
113
+ agentId: subAgentId,
114
+ worktree: mergeResult ? {
115
+ merged: mergeResult.merged,
116
+ conflicts: mergeResult.conflicts,
117
+ commitCount: mergeResult.commitCount,
118
+ } : undefined,
119
+ };
61
120
  }
62
121
  catch (err) {
63
122
  subAgent.abort();
64
- logger.error('Sub-agent failed', { model, error: err.message });
123
+ logger.error('Sub-agent failed', { agentId: subAgentId, model, error: err.message });
65
124
  throw err;
66
125
  }
67
126
  finally {
68
127
  clearTimeout(timer);
128
+ if (worktreeInfo) {
129
+ try {
130
+ await removeWorktree(worktreeInfo, process.cwd());
131
+ }
132
+ catch (err) {
133
+ logger.warn('Worktree cleanup failed', { error: err.message });
134
+ }
135
+ }
69
136
  }
70
137
  }
138
+ export async function spawnParallelAgents(parentConfig, requests) {
139
+ logger.info('Spawning parallel agents', { count: requests.length });
140
+ return Promise.allSettled(requests.map(r => spawnSubAgent(parentConfig, r.prompt, {
141
+ ...r.options,
142
+ allowAgentTool: r.options?.allowAgentTool ?? true,
143
+ agentDescription: r.description,
144
+ })));
145
+ }
71
146
  export function pickCheapestModel(models) {
72
147
  const cheapIndicators = CHEAP_MODEL_INDICATORS;
73
148
  for (const model of [...models].reverse()) {
@@ -0,0 +1,105 @@
1
+ import { EventEmitter } from 'eventemitter3';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { logger } from './logger.js';
4
+ export class TaskBoard extends EventEmitter {
5
+ static instance = null;
6
+ tasks = new Map();
7
+ constructor() {
8
+ super();
9
+ }
10
+ static getInstance() {
11
+ if (!TaskBoard.instance) {
12
+ TaskBoard.instance = new TaskBoard();
13
+ }
14
+ return TaskBoard.instance;
15
+ }
16
+ static resetInstance() {
17
+ if (TaskBoard.instance) {
18
+ TaskBoard.instance.removeAllListeners();
19
+ TaskBoard.instance.tasks.clear();
20
+ }
21
+ TaskBoard.instance = null;
22
+ }
23
+ create(agentId, title, description) {
24
+ const task = {
25
+ id: randomUUID().slice(0, 8),
26
+ title,
27
+ description,
28
+ status: 'open',
29
+ assignee: null,
30
+ createdBy: agentId,
31
+ createdAt: Date.now(),
32
+ updatedAt: Date.now(),
33
+ };
34
+ this.tasks.set(task.id, task);
35
+ this.emit('task:created', task);
36
+ logger.debug('Task created', { id: task.id, title, by: agentId });
37
+ return task;
38
+ }
39
+ claim(taskId, agentId) {
40
+ const task = this.tasks.get(taskId);
41
+ if (!task) {
42
+ return { error: `Task "${taskId}" not found` };
43
+ }
44
+ if (task.status !== 'open') {
45
+ return { error: `Task "${taskId}" is not open (status: ${task.status}, assignee: ${task.assignee})` };
46
+ }
47
+ task.assignee = agentId;
48
+ task.status = 'claimed';
49
+ task.updatedAt = Date.now();
50
+ this.emit('task:claimed', task);
51
+ logger.debug('Task claimed', { id: taskId, by: agentId });
52
+ return task;
53
+ }
54
+ update(taskId, agentId, updates) {
55
+ const task = this.tasks.get(taskId);
56
+ if (!task) {
57
+ return { error: `Task "${taskId}" not found` };
58
+ }
59
+ if (task.assignee && task.assignee !== agentId && task.createdBy !== agentId) {
60
+ return { error: `Agent "${agentId}" is not the assignee or creator of task "${taskId}"` };
61
+ }
62
+ if (updates.status) {
63
+ task.status = updates.status;
64
+ if (updates.status === 'in_progress' && !task.assignee) {
65
+ task.assignee = agentId;
66
+ }
67
+ }
68
+ if (updates.result !== undefined) {
69
+ task.result = updates.result;
70
+ }
71
+ task.updatedAt = Date.now();
72
+ if (task.status === 'completed' || task.status === 'failed') {
73
+ this.emit('task:completed', task);
74
+ }
75
+ else {
76
+ this.emit('task:updated', task);
77
+ }
78
+ logger.debug('Task updated', { id: taskId, status: task.status, by: agentId });
79
+ return task;
80
+ }
81
+ list(filter) {
82
+ let tasks = [...this.tasks.values()];
83
+ if (filter?.status) {
84
+ tasks = tasks.filter(t => t.status === filter.status);
85
+ }
86
+ if (filter?.assignee) {
87
+ tasks = tasks.filter(t => t.assignee === filter.assignee);
88
+ }
89
+ return tasks;
90
+ }
91
+ get(taskId) {
92
+ return this.tasks.get(taskId);
93
+ }
94
+ getStats() {
95
+ const all = [...this.tasks.values()];
96
+ return {
97
+ total: all.length,
98
+ open: all.filter(t => t.status === 'open').length,
99
+ claimed: all.filter(t => t.status === 'claimed').length,
100
+ inProgress: all.filter(t => t.status === 'in_progress').length,
101
+ completed: all.filter(t => t.status === 'completed').length,
102
+ failed: all.filter(t => t.status === 'failed').length,
103
+ };
104
+ }
105
+ }