scalix 0.0.1 → 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.
Files changed (57) hide show
  1. package/.env.example +29 -0
  2. package/CHANGELOG.md +122 -0
  3. package/LICENSE +100 -0
  4. package/README.md +300 -2
  5. package/bin/scalix.js +19 -0
  6. package/dist/agent-definitions.js +100 -0
  7. package/dist/agent-registry.js +226 -0
  8. package/dist/agent-replay.js +166 -0
  9. package/dist/agent.js +1130 -0
  10. package/dist/api-client.js +77 -0
  11. package/dist/auto-verify.js +136 -0
  12. package/dist/change-tracker.js +53 -0
  13. package/dist/cli.js +2454 -0
  14. package/dist/commands.js +71 -0
  15. package/dist/config.js +16 -0
  16. package/dist/constants.js +358 -0
  17. package/dist/context-builder.js +158 -0
  18. package/dist/context-manager.js +148 -0
  19. package/dist/context-retriever.js +257 -0
  20. package/dist/cost-tracker.js +85 -0
  21. package/dist/diff.js +140 -0
  22. package/dist/git-context.js +183 -0
  23. package/dist/git-undo.js +67 -0
  24. package/dist/grounding-checker.js +154 -0
  25. package/dist/headless.js +743 -0
  26. package/dist/hooks.js +100 -0
  27. package/dist/injection-detector.js +86 -0
  28. package/dist/ipc-server.js +232 -0
  29. package/dist/logger.js +45 -0
  30. package/dist/markdown.js +41 -0
  31. package/dist/mcp.js +49 -0
  32. package/dist/model-router.js +68 -0
  33. package/dist/plugins.js +132 -0
  34. package/dist/project-index.js +376 -0
  35. package/dist/project-memory.js +165 -0
  36. package/dist/safe-math.js +194 -0
  37. package/dist/sandbox.js +403 -0
  38. package/dist/scoped-rules.js +82 -0
  39. package/dist/sessions.js +106 -0
  40. package/dist/settings.js +260 -0
  41. package/dist/sub-agent.js +154 -0
  42. package/dist/task-board.js +105 -0
  43. package/dist/token-counter.js +47 -0
  44. package/dist/tool-helper.js +97 -0
  45. package/dist/tool-selector.js +77 -0
  46. package/dist/tools-advanced.js +487 -0
  47. package/dist/tools-browser.js +534 -0
  48. package/dist/tools-extra.js +390 -0
  49. package/dist/tools-vision.js +116 -0
  50. package/dist/tools-voice.js +147 -0
  51. package/dist/tools-web.js +346 -0
  52. package/dist/tools.js +380 -0
  53. package/dist/types.js +1 -0
  54. package/dist/update-checker.js +111 -0
  55. package/dist/worktree.js +172 -0
  56. package/docs/DISTRIBUTION.md +301 -0
  57. package/package.json +71 -6
@@ -0,0 +1,106 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { logger } from './logger.js';
6
+ import { USER_CONFIG_DIR } from './constants.js';
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
+ }
33
+ async function ensureDir() {
34
+ await fs.mkdir(SESSIONS_DIR, { recursive: true });
35
+ await cleanupStaleTempFiles();
36
+ }
37
+ function sessionPath(id) {
38
+ return path.join(SESSIONS_DIR, `${id}.json`);
39
+ }
40
+ export async function saveSession(id, messages, model, name, costState) {
41
+ await ensureDir();
42
+ const existing = await loadSession(id).catch(() => null);
43
+ const now = new Date().toISOString();
44
+ const meta = {
45
+ id,
46
+ name: name || existing?.meta.name || `Session ${id.slice(0, 8)}`,
47
+ model,
48
+ messageCount: messages.length,
49
+ createdAt: existing?.meta.createdAt || now,
50
+ updatedAt: now,
51
+ };
52
+ const data = { meta, messages, costState };
53
+ // Atomic write: write to temp file then rename to prevent corruption
54
+ const tmpPath = path.join(os.tmpdir(), `scalix-session-${id}-${Date.now()}-${randomUUID().slice(0, 8)}.tmp`);
55
+ await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 });
56
+ await fs.rename(tmpPath, sessionPath(id));
57
+ return meta;
58
+ }
59
+ export async function loadSession(id) {
60
+ const raw = await fs.readFile(sessionPath(id), 'utf-8');
61
+ let data;
62
+ try {
63
+ data = JSON.parse(raw);
64
+ }
65
+ catch {
66
+ throw new Error(`Corrupted session file: ${id}`);
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;
74
+ }
75
+ export async function listSessions() {
76
+ await ensureDir();
77
+ const files = await fs.readdir(SESSIONS_DIR);
78
+ const sessions = [];
79
+ for (const file of files) {
80
+ if (!file.endsWith('.json'))
81
+ continue;
82
+ try {
83
+ const raw = await fs.readFile(path.join(SESSIONS_DIR, file), 'utf-8');
84
+ const data = JSON.parse(raw);
85
+ if (data?.meta?.id && data?.meta?.updatedAt) {
86
+ sessions.push(data.meta);
87
+ }
88
+ }
89
+ catch (e) {
90
+ logger.warn('Failed to read session file', { file, error: e.message });
91
+ }
92
+ }
93
+ return sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
94
+ }
95
+ export async function deleteSession(id) {
96
+ try {
97
+ await fs.unlink(sessionPath(id));
98
+ return true;
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
104
+ export function generateSessionId() {
105
+ return randomUUID();
106
+ }
@@ -0,0 +1,260 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
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
+ }
11
+ export const THINKING_MODES = ['off', 'low', 'medium', 'high'];
12
+ export const DEFAULT_SECURITY = {};
13
+ export const DEFAULT_LIMITS = {
14
+ maxSteps: AGENT_DEFAULTS.maxSteps,
15
+ maxMessages: AGENT_DEFAULTS.maxMessages,
16
+ maxContextTokens: CONTEXT_DEFAULTS.maxContextTokens,
17
+ compressionThreshold: CONTEXT_DEFAULTS.compressionThreshold,
18
+ maxToolResultTokens: CONTEXT_DEFAULTS.maxToolResultTokens,
19
+ toolTimeoutMs: AGENT_DEFAULTS.toolTimeoutMs,
20
+ maxRetries: AGENT_DEFAULTS.maxRetries,
21
+ messageKeepRatio: AGENT_DEFAULTS.messageKeepRatio,
22
+ circuitBreakerThreshold: AGENT_DEFAULTS.circuitBreakerThreshold,
23
+ circuitBreakerResetMs: AGENT_DEFAULTS.circuitBreakerResetMs,
24
+ commandTimeoutMs: TOOL_DEFAULTS.commandTimeoutMs,
25
+ webFetchTimeoutMs: WEB_DEFAULTS.fetchTimeoutMs,
26
+ htmlMaxSize: WEB_DEFAULTS.htmlMaxSize,
27
+ defaultSearchResults: WEB_DEFAULTS.defaultSearchResults,
28
+ defaultMaxFetchLength: WEB_DEFAULTS.defaultMaxLength,
29
+ bgBufferMax: TOOL_DEFAULTS.bgBufferMax,
30
+ bgCleanupMs: TOOL_DEFAULTS.bgCleanupMs,
31
+ searchTimeoutMs: TOOL_DEFAULTS.searchTimeoutMs,
32
+ fileRefTruncation: TOOL_DEFAULTS.fileRefTruncation,
33
+ charsPerToken: CONTEXT_DEFAULTS.charsPerToken,
34
+ minRecentMessages: CONTEXT_DEFAULTS.minRecentMessages,
35
+ subAgentTimeoutMs: SUB_AGENT_DEFAULTS.timeoutMs,
36
+ subAgentMaxSteps: SUB_AGENT_DEFAULTS.maxSteps,
37
+ subAgentMaxContextTokens: SUB_AGENT_DEFAULTS.maxContextTokens,
38
+ subAgentCompressionThreshold: SUB_AGENT_DEFAULTS.compressionThreshold,
39
+ agentToolTimeoutMs: SUB_AGENT_DEFAULTS.agentToolTimeoutMs,
40
+ exploreMaxSteps: SUB_AGENT_DEFAULTS.exploreMaxSteps,
41
+ shellDetectionTimeoutMs: TOOL_DEFAULTS.shellDetectionTimeoutMs,
42
+ treeMaxEntries: TOOL_DEFAULTS.treeMaxEntries,
43
+ gitTimeoutMs: TOOL_DEFAULTS.gitTimeoutMs,
44
+ gitDiffMaxBuffer: TOOL_DEFAULTS.gitDiffMaxBuffer,
45
+ readFileMaxSize: TOOL_DEFAULTS.readFileMaxSize,
46
+ maxEditStringLength: TOOL_DEFAULTS.maxEditStringLength,
47
+ maxNotebookSource: TOOL_DEFAULTS.maxNotebookSource,
48
+ maxWebFetchLength: TOOL_DEFAULTS.maxWebFetchLength,
49
+ slidingWindowTarget: CONTEXT_DEFAULTS.slidingWindowTarget,
50
+ maxConcurrentAgents: MULTI_AGENT_DEFAULTS.maxConcurrentAgents,
51
+ maxAgentDepth: MULTI_AGENT_DEFAULTS.maxDepth,
52
+ globalTokenBudget: MULTI_AGENT_DEFAULTS.globalTokenBudget,
53
+ };
54
+ const DEFAULTS = {
55
+ apiKey: '',
56
+ baseUrl: DEFAULT_BASE_URL,
57
+ models: [...DEFAULT_MODELS],
58
+ customModels: {},
59
+ limits: { ...DEFAULT_LIMITS },
60
+ permissionMode: 'default',
61
+ thinkingMode: 'off',
62
+ security: { ...DEFAULT_SECURITY },
63
+ injectionDetection: { enabled: true, sensitivity: 'medium' },
64
+ disableUpdateCheck: false,
65
+ };
66
+ function safeNumber(val, min, max, fallback) {
67
+ if (typeof val === 'number' && Number.isFinite(val) && val >= min && val <= max)
68
+ return val;
69
+ return fallback;
70
+ }
71
+ function validateConfig(parsed) {
72
+ const safe = {};
73
+ if (typeof parsed.apiKey === 'string')
74
+ safe.apiKey = parsed.apiKey;
75
+ if (typeof parsed.baseUrl === 'string' && parsed.baseUrl.length > 0)
76
+ safe.baseUrl = parsed.baseUrl;
77
+ if (Array.isArray(parsed.models) && parsed.models.every((m) => typeof m === 'string')) {
78
+ safe.models = parsed.models;
79
+ }
80
+ if (typeof parsed.permissionMode === 'string' && ['plan', 'default', 'auto', 'yolo'].includes(parsed.permissionMode)) {
81
+ safe.permissionMode = parsed.permissionMode;
82
+ }
83
+ if (parsed.customModels && typeof parsed.customModels === 'object' && !Array.isArray(parsed.customModels)) {
84
+ const validated = {};
85
+ for (const [id, spec] of Object.entries(parsed.customModels)) {
86
+ const s = spec;
87
+ if (typeof s === 'object' && s &&
88
+ typeof s.contextWindow === 'number' && s.contextWindow > 0 &&
89
+ typeof s.maxOutputTokens === 'number' && s.maxOutputTokens > 0) {
90
+ validated[id] = { contextWindow: s.contextWindow, maxOutputTokens: s.maxOutputTokens };
91
+ }
92
+ }
93
+ if (Object.keys(validated).length > 0) {
94
+ safe.customModels = validated;
95
+ }
96
+ }
97
+ if (parsed.limits && typeof parsed.limits === 'object' && !Array.isArray(parsed.limits)) {
98
+ const l = parsed.limits;
99
+ safe.limits = {
100
+ maxSteps: safeNumber(l.maxSteps, 1, Infinity, DEFAULT_LIMITS.maxSteps),
101
+ maxMessages: safeNumber(l.maxMessages, 1, Infinity, DEFAULT_LIMITS.maxMessages),
102
+ maxContextTokens: safeNumber(l.maxContextTokens, 1000, Infinity, DEFAULT_LIMITS.maxContextTokens),
103
+ compressionThreshold: safeNumber(l.compressionThreshold, 0.1, 0.99, DEFAULT_LIMITS.compressionThreshold),
104
+ maxToolResultTokens: safeNumber(l.maxToolResultTokens, 100, Infinity, DEFAULT_LIMITS.maxToolResultTokens),
105
+ toolTimeoutMs: safeNumber(l.toolTimeoutMs, 1000, 600_000, DEFAULT_LIMITS.toolTimeoutMs),
106
+ maxRetries: safeNumber(l.maxRetries, 0, 20, DEFAULT_LIMITS.maxRetries),
107
+ messageKeepRatio: safeNumber(l.messageKeepRatio, 0.1, 0.99, DEFAULT_LIMITS.messageKeepRatio),
108
+ circuitBreakerThreshold: safeNumber(l.circuitBreakerThreshold, 1, 50, DEFAULT_LIMITS.circuitBreakerThreshold),
109
+ circuitBreakerResetMs: safeNumber(l.circuitBreakerResetMs, 1000, 600_000, DEFAULT_LIMITS.circuitBreakerResetMs),
110
+ commandTimeoutMs: safeNumber(l.commandTimeoutMs, 1000, 600_000, DEFAULT_LIMITS.commandTimeoutMs),
111
+ webFetchTimeoutMs: safeNumber(l.webFetchTimeoutMs, 1000, 120_000, DEFAULT_LIMITS.webFetchTimeoutMs),
112
+ htmlMaxSize: safeNumber(l.htmlMaxSize, 1024, Infinity, DEFAULT_LIMITS.htmlMaxSize),
113
+ defaultSearchResults: safeNumber(l.defaultSearchResults, 1, 100, DEFAULT_LIMITS.defaultSearchResults),
114
+ defaultMaxFetchLength: safeNumber(l.defaultMaxFetchLength, 1000, Infinity, DEFAULT_LIMITS.defaultMaxFetchLength),
115
+ bgBufferMax: safeNumber(l.bgBufferMax, 1024, Infinity, DEFAULT_LIMITS.bgBufferMax),
116
+ bgCleanupMs: safeNumber(l.bgCleanupMs, 60_000, 86_400_000, DEFAULT_LIMITS.bgCleanupMs),
117
+ searchTimeoutMs: safeNumber(l.searchTimeoutMs, 1000, 600_000, DEFAULT_LIMITS.searchTimeoutMs),
118
+ fileRefTruncation: safeNumber(l.fileRefTruncation, 1000, Infinity, DEFAULT_LIMITS.fileRefTruncation),
119
+ charsPerToken: safeNumber(l.charsPerToken, 1, 10, DEFAULT_LIMITS.charsPerToken),
120
+ minRecentMessages: safeNumber(l.minRecentMessages, 1, 100, DEFAULT_LIMITS.minRecentMessages),
121
+ subAgentTimeoutMs: safeNumber(l.subAgentTimeoutMs, 5000, Infinity, DEFAULT_LIMITS.subAgentTimeoutMs),
122
+ subAgentMaxSteps: safeNumber(l.subAgentMaxSteps, 1, Infinity, DEFAULT_LIMITS.subAgentMaxSteps),
123
+ subAgentMaxContextTokens: safeNumber(l.subAgentMaxContextTokens, 1000, Infinity, DEFAULT_LIMITS.subAgentMaxContextTokens),
124
+ subAgentCompressionThreshold: safeNumber(l.subAgentCompressionThreshold, 0.1, 0.99, DEFAULT_LIMITS.subAgentCompressionThreshold),
125
+ agentToolTimeoutMs: safeNumber(l.agentToolTimeoutMs, 5000, Infinity, DEFAULT_LIMITS.agentToolTimeoutMs),
126
+ exploreMaxSteps: safeNumber(l.exploreMaxSteps, 1, Infinity, DEFAULT_LIMITS.exploreMaxSteps),
127
+ shellDetectionTimeoutMs: safeNumber(l.shellDetectionTimeoutMs, 500, 30_000, DEFAULT_LIMITS.shellDetectionTimeoutMs),
128
+ treeMaxEntries: safeNumber(l.treeMaxEntries, 10, Infinity, DEFAULT_LIMITS.treeMaxEntries),
129
+ gitTimeoutMs: safeNumber(l.gitTimeoutMs, 1000, 120_000, DEFAULT_LIMITS.gitTimeoutMs),
130
+ gitDiffMaxBuffer: safeNumber(l.gitDiffMaxBuffer, 1024, Infinity, DEFAULT_LIMITS.gitDiffMaxBuffer),
131
+ readFileMaxSize: safeNumber(l.readFileMaxSize, 1024, Infinity, DEFAULT_LIMITS.readFileMaxSize),
132
+ maxEditStringLength: safeNumber(l.maxEditStringLength, 1000, Infinity, DEFAULT_LIMITS.maxEditStringLength),
133
+ maxNotebookSource: safeNumber(l.maxNotebookSource, 1000, Infinity, DEFAULT_LIMITS.maxNotebookSource),
134
+ maxWebFetchLength: safeNumber(l.maxWebFetchLength, 1000, Infinity, DEFAULT_LIMITS.maxWebFetchLength),
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),
139
+ };
140
+ }
141
+ if (typeof parsed.thinkingMode === 'string' && THINKING_MODES.includes(parsed.thinkingMode)) {
142
+ safe.thinkingMode = parsed.thinkingMode;
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
+ }
161
+ return safe;
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
+ }
225
+ async function ensureDir() {
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
+ });
235
+ }
236
+ export async function loadConfig() {
237
+ try {
238
+ const raw = await fs.readFile(getConfigFile(), 'utf-8');
239
+ const parsed = JSON.parse(raw);
240
+ const validated = validateConfig(parsed);
241
+ const config = { ...DEFAULTS, ...validated };
242
+ if (config.customModels && Object.keys(config.customModels).length > 0) {
243
+ registerUserModels(config.customModels);
244
+ }
245
+ if (config.security && Object.keys(config.security).length > 0) {
246
+ applySecurityConfig(config.security);
247
+ }
248
+ return config;
249
+ }
250
+ catch {
251
+ return { ...DEFAULTS };
252
+ }
253
+ }
254
+ export async function saveConfig(config) {
255
+ await ensureDir();
256
+ await fs.writeFile(getConfigFile(), JSON.stringify(config, null, 2), { encoding: 'utf-8', mode: 0o600 });
257
+ }
258
+ export function isConfigured(config) {
259
+ return config.apiKey.length > 0 && config.models.length > 0;
260
+ }
@@ -0,0 +1,154 @@
1
+ import { createAgent } from './agent.js';
2
+ import { defaultTools } from './tools.js';
3
+ import { extraTools } from './tools-extra.js';
4
+ import { webTools } from './tools-web.js';
5
+ import { advancedTools } from './tools-advanced.js';
6
+ import { logger } from './logger.js';
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';
10
+ const READ_ONLY_TOOL_NAMES = new Set([
11
+ 'get_current_time', 'calculate', 'read_file', 'read_file_lines',
12
+ 'list_files', 'search_files', 'find_files', 'tree',
13
+ 'git_status', 'git_diff', 'web_search', 'monitor',
14
+ ]);
15
+ function selectTools(level, allowAgentTool = false) {
16
+ const all = [...defaultTools, ...extraTools, ...webTools, ...advancedTools]
17
+ .filter((t) => {
18
+ const name = t.function?.name || t.name;
19
+ if (name === 'agent' && !allowAgentTool)
20
+ return false;
21
+ return true;
22
+ });
23
+ switch (level) {
24
+ case 'read-only':
25
+ return all.filter((t) => READ_ONLY_TOOL_NAMES.has(t.function?.name || t.name));
26
+ case 'all':
27
+ return all;
28
+ case 'standard':
29
+ default:
30
+ return all;
31
+ }
32
+ }
33
+ export async function spawnSubAgent(parentConfig, prompt, options = {}) {
34
+ const model = options.model || parentConfig.models[parentConfig.models.length - 1];
35
+ const allowAgentTool = options.allowAgentTool ?? false;
36
+ const tools = selectTools(options.tools, allowAgentTool);
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
+ }
46
+ const permissionMode = options.permissionMode
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';
61
+ const subAgent = createAgent({
62
+ apiKey: parentConfig.apiKey,
63
+ baseUrl: parentConfig.baseUrl,
64
+ model,
65
+ instructions: options.instructions || 'You are a focused research assistant. Be concise and factual. Respond in under 500 words.',
66
+ tools,
67
+ permissionMode,
68
+ confirmToolCall: options.confirmToolCall,
69
+ maxSteps: options.maxSteps ?? SUB_AGENT_DEFAULTS.maxSteps,
70
+ contextManager: {
71
+ maxContextTokens: SUB_AGENT_DEFAULTS.maxContextTokens,
72
+ compressionThreshold: SUB_AGENT_DEFAULTS.compressionThreshold,
73
+ },
74
+ agentId: subAgentId,
75
+ parentAgentId: parentId,
76
+ agentDescription: description,
77
+ groundingCheck: true,
78
+ contextRetrieval: !isReadOnly,
79
+ });
80
+ let steps = 0;
81
+ let tokenEstimate = 0;
82
+ subAgent.on('tool:call', () => { steps++; });
83
+ subAgent.on('usage', (data) => {
84
+ tokenEstimate += data.total_tokens ?? 0;
85
+ });
86
+ let timer;
87
+ const timeoutPromise = new Promise((_, reject) => {
88
+ timer = setTimeout(() => reject(new Error(`Sub-agent timed out after ${timeout}ms`)), timeout);
89
+ });
90
+ try {
91
+ const response = await Promise.race([subAgent.send(prompt), timeoutPromise]);
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
+ };
120
+ }
121
+ catch (err) {
122
+ subAgent.abort();
123
+ logger.error('Sub-agent failed', { agentId: subAgentId, model, error: err.message });
124
+ throw err;
125
+ }
126
+ finally {
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
+ }
136
+ }
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
+ }
146
+ export function pickCheapestModel(models) {
147
+ const cheapIndicators = CHEAP_MODEL_INDICATORS;
148
+ for (const model of [...models].reverse()) {
149
+ const lower = model.toLowerCase();
150
+ if (cheapIndicators.some((ind) => lower.includes(ind)))
151
+ return model;
152
+ }
153
+ return models[models.length - 1];
154
+ }
@@ -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
+ }
@@ -0,0 +1,47 @@
1
+ import { TOKEN_ESTIMATION } from './constants.js';
2
+ const CODE_CHARS = /[{}()\[\]<>;:,."'`=+\-*/%!&|^~?@#$\\]/g;
3
+ const UNICODE_MULTI = /[\u0080-\uffff]/g;
4
+ const WORD_SPLIT = /\s+/;
5
+ export function estimateTokens(text) {
6
+ if (!text)
7
+ return 0;
8
+ const len = text.length;
9
+ if (len < TOKEN_ESTIMATION.simpleCharsPerToken)
10
+ return 1;
11
+ const codeChars = (text.match(CODE_CHARS) || []).length;
12
+ const unicodeChars = (text.match(UNICODE_MULTI) || []).length;
13
+ const words = text.split(WORD_SPLIT).filter(Boolean).length;
14
+ const codeRatio = codeChars / len;
15
+ const charsPerToken = codeRatio > TOKEN_ESTIMATION.codeRatioThreshold
16
+ ? TOKEN_ESTIMATION.codeCharsPerToken
17
+ : TOKEN_ESTIMATION.textCharsPerToken;
18
+ const charEstimate = (len - unicodeChars) / charsPerToken + unicodeChars * TOKEN_ESTIMATION.unicodeTokenMultiplier;
19
+ const wordEstimate = words * TOKEN_ESTIMATION.wordTokenMultiplier + codeChars * TOKEN_ESTIMATION.codeCharTokenMultiplier;
20
+ return Math.max(1, Math.ceil(charEstimate * TOKEN_ESTIMATION.charEstimateWeight + wordEstimate * TOKEN_ESTIMATION.wordEstimateWeight));
21
+ }
22
+ function contentToString(content) {
23
+ if (!content)
24
+ return '';
25
+ if (typeof content === 'string')
26
+ return content;
27
+ return content
28
+ .filter((p) => p.type === 'text')
29
+ .map((p) => p.text)
30
+ .join('\n');
31
+ }
32
+ export function estimateMessageTokens(messages) {
33
+ let total = 0;
34
+ for (const msg of messages) {
35
+ total += TOKEN_ESTIMATION.messageOverhead;
36
+ if (msg.content)
37
+ total += estimateTokens(contentToString(msg.content));
38
+ if (msg.tool_calls) {
39
+ for (const tc of msg.tool_calls) {
40
+ total += estimateTokens(tc.function.name) + estimateTokens(tc.function.arguments) + TOKEN_ESTIMATION.messageOverhead;
41
+ }
42
+ }
43
+ if (msg.name)
44
+ total += estimateTokens(msg.name);
45
+ }
46
+ return total;
47
+ }