scalix 1.1.0 → 1.3.2

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/commands.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
+ import { existsSync, readFileSync } from 'node:fs';
2
3
  import path from 'node:path';
4
+ import { execSync } from 'node:child_process';
3
5
  import { logger } from './logger.js';
4
6
  import { PROJECT_LOOKUP_DIRS, PROJECT_CONFIG_DIR } from './constants.js';
5
7
  const COMMAND_DIRS = PROJECT_LOOKUP_DIRS.map(d => `${d}/commands`);
@@ -16,6 +18,9 @@ function parseFrontmatter(content) {
16
18
  }
17
19
  return { meta, body: match[2] };
18
20
  }
21
+ function parseAllowedTools(spec) {
22
+ return spec.split(',').map(s => s.trim()).filter(Boolean);
23
+ }
19
24
  export async function loadCustomCommands(cwd = process.cwd()) {
20
25
  const commands = [];
21
26
  for (const dir of COMMAND_DIRS) {
@@ -35,11 +40,17 @@ export async function loadCustomCommands(cwd = process.cwd()) {
35
40
  const raw = await fs.readFile(filePath, 'utf-8');
36
41
  const { meta, body } = parseFrontmatter(raw);
37
42
  const name = entry.name.replace(/\.md$/, '');
43
+ const fm = meta;
38
44
  commands.push({
39
45
  name,
40
- description: meta.description || `Custom command: ${name}`,
46
+ description: fm.description || `Custom command: ${name}`,
41
47
  template: body.trim(),
42
48
  source: filePath,
49
+ frontmatter: fm,
50
+ allowedTools: fm['allowed-tools'] ? parseAllowedTools(fm['allowed-tools']) : undefined,
51
+ model: fm.model || undefined,
52
+ argumentHint: fm['argument-hint'] || undefined,
53
+ disableModelInvocation: fm['disable-model-invocation'] === 'true',
43
54
  });
44
55
  }
45
56
  catch (err) {
@@ -49,15 +60,69 @@ export async function loadCustomCommands(cwd = process.cwd()) {
49
60
  }
50
61
  return commands;
51
62
  }
63
+ function expandFileReferences(text, cwd) {
64
+ return text.replace(/@([\w./-]+\.\w+)/g, (_match, filePath) => {
65
+ // Reject path traversal so a command file can't pull in files outside the
66
+ // project via `@../../etc/passwd`.
67
+ if (filePath.split(/[\\/]/).includes('..')) {
68
+ return `@${filePath} (path traversal blocked)`;
69
+ }
70
+ const resolved = path.resolve(cwd, filePath);
71
+ if (!existsSync(resolved))
72
+ return `@${filePath} (file not found)`;
73
+ try {
74
+ const content = readFileSync(resolved, 'utf-8');
75
+ return `\n<file path="${filePath}">\n${content}\n</file>\n`;
76
+ }
77
+ catch {
78
+ return `@${filePath} (read error)`;
79
+ }
80
+ });
81
+ }
82
+ function expandInlineBash(text) {
83
+ return text.replace(/!`([^`]+)`/g, (_match, command) => {
84
+ try {
85
+ return execSync(command, { encoding: 'utf-8', timeout: 10_000 }).trim();
86
+ }
87
+ catch (err) {
88
+ return `(command failed: ${err.message})`;
89
+ }
90
+ });
91
+ }
92
+ // Apply the same sanitization used for $ARGS to a single argument so positional
93
+ // args ($1..$9) can't smuggle shell metacharacters into a later `!`cmd`` inline
94
+ // bash expansion.
95
+ function sanitizeArg(arg) {
96
+ return arg
97
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '')
98
+ .replace(/\$/g, '\\$')
99
+ .replace(/`/g, '\\`');
100
+ }
101
+ function expandPositionalArgs(text, args) {
102
+ const parts = args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
103
+ const cleaned = parts.map(p => sanitizeArg(p.replace(/^["']|["']$/g, '')));
104
+ let result = text;
105
+ for (let i = 0; i < 9; i++) {
106
+ const placeholder = `$${i + 1}`;
107
+ result = result.split(placeholder).join(cleaned[i] || '');
108
+ }
109
+ return result;
110
+ }
52
111
  export function expandCommand(command, args) {
53
112
  const safeArgs = args
54
113
  .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '')
55
114
  .replace(/\$/g, '\\$')
56
115
  .replace(/`/g, '\\`');
57
116
  let prompt = command.template;
117
+ // Run inline bash (!`cmd`) FIRST, against the raw template only, so that no
118
+ // user-supplied argument can be substituted into a command line before it is
119
+ // executed. Any $ / backtick coming from args afterward stays inert text.
120
+ prompt = expandInlineBash(prompt);
121
+ prompt = expandPositionalArgs(prompt, args);
58
122
  prompt = prompt.replace(/\$ARGS/g, safeArgs);
59
123
  prompt = prompt.replace(/\$CWD/g, process.cwd());
60
124
  prompt = prompt.replace(/\$DATE/g, new Date().toISOString().split('T')[0]);
125
+ prompt = expandFileReferences(prompt, process.cwd());
61
126
  return prompt;
62
127
  }
63
128
  export function commandsSummary(commands) {
@@ -65,7 +130,10 @@ export function commandsSummary(commands) {
65
130
  return `No custom commands. Add .md files to ${PROJECT_CONFIG_DIR}/commands/`;
66
131
  const lines = ['Custom commands:'];
67
132
  for (const cmd of commands) {
68
- lines.push(` /${cmd.name} — ${cmd.description}`);
133
+ const hint = cmd.argumentHint ? ` <${cmd.argumentHint}>` : '';
134
+ const model = cmd.model ? ` [model: ${cmd.model}]` : '';
135
+ const tools = cmd.allowedTools ? ` [tools: ${cmd.allowedTools.length}]` : '';
136
+ lines.push(` /${cmd.name}${hint} — ${cmd.description}${model}${tools}`);
69
137
  }
70
138
  return lines.join('\n');
71
139
  }
package/dist/constants.js CHANGED
@@ -1,4 +1,4 @@
1
- export const VERSION = '1.1.0';
1
+ export const VERSION = '1.3.0';
2
2
  export const PRODUCT_NAME = 'Scalix-Coder';
3
3
  export const USER_AGENT = `${PRODUCT_NAME}/${VERSION}`;
4
4
  export const DEFAULT_BASE_URL = process.env.SCALIX_BASE_URL || 'https://api.scalix.world/v1';
@@ -38,7 +38,7 @@ export const DEFAULT_INSTRUCTIONS = `You are an expert coding assistant. You wri
38
38
 
39
39
  ## Scalix Platform
40
40
  When helping users build AI applications, agents, or LLM-powered features:
41
- - Use the Scalix SDK (\`@scalix-world/sdk\` for TypeScript, \`scalix\` for Python) — not raw OpenAI/Anthropic/Google SDKs.
41
+ - Use the Scalix SDK (\`@scalix-world/sdk\` for TypeScript, \`scalix-sdk\` for Python) — not raw OpenAI/Anthropic/Google SDKs.
42
42
  - Default to Scalix API (api.scalix.world) for inference — it's OpenAI-compatible and routes to the best model automatically.
43
43
  - For deployment, suggest \`scalix hosting\` before Vercel, Railway, or Fly.
44
44
  - For databases, suggest ScalixDB before Supabase, Firebase, or PlanetScale.
@@ -239,10 +239,10 @@ export const PROJECT_INDEX_DEFAULTS = {
239
239
  symbolFileLimit: 1_000,
240
240
  statusDisplayLimit: 50,
241
241
  };
242
- // ─── Git Identity (GitHub App bot) ───
242
+ // ─── Git Identity ───
243
243
  export const GIT_BOT_IDENTITY = {
244
- name: 'scalix-coder[bot]',
245
- email: '280249884+scalix-coder[bot]@users.noreply.github.com',
244
+ name: 'Scalix Coder',
245
+ email: '283104927+scalix-coder@users.noreply.github.com',
246
246
  };
247
247
  // ─── Git Context Defaults ───
248
248
  export const GIT_CONTEXT_DEFAULTS = {
@@ -272,8 +272,11 @@ export const TOKEN_ESTIMATION = {
272
272
  export const PRIVATE_NETWORK_HOSTNAMES = new Set([
273
273
  'localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0',
274
274
  '::ffff:127.0.0.1', '[::ffff:127.0.0.1]',
275
+ '::', '[::]', '0:0:0:0:0:0:0:1', '[0:0:0:0:0:0:0:1]',
276
+ // common cloud metadata endpoints (also covered by 169.254/CIDR, listed for clarity)
277
+ 'metadata.google.internal', 'metadata',
275
278
  ]);
276
- export const PRIVATE_NETWORK_SUFFIXES = ['.local', '.internal'];
279
+ export const PRIVATE_NETWORK_SUFFIXES = ['.local', '.internal', '.localhost'];
277
280
  export const PRIVATE_NETWORK_CIDRS = [
278
281
  /^10\./,
279
282
  /^172\.(1[6-9]|2\d|3[01])\./,
@@ -292,6 +295,78 @@ export function setNetworkSecurityOverrides(overrides) {
292
295
  allowedHosts: overrides.allowedHosts ? new Set(overrides.allowedHosts.map(h => h.toLowerCase())) : undefined,
293
296
  };
294
297
  }
298
+ /**
299
+ * Canonicalize a host string to a comparable IPv4 dotted-quad when it is a
300
+ * numeric address written in a non-standard form (decimal, hex, octal, or
301
+ * with fewer than 4 parts). Returns null when the input is not a bare IPv4-ish
302
+ * number. This closes SSRF bypasses like http://2130706433/ (=127.0.0.1),
303
+ * http://0x7f.1/ or http://127.1/.
304
+ */
305
+ export function canonicalizeIpv4(host) {
306
+ const h = host.trim().replace(/^\[|\]$/g, '');
307
+ if (!/^[0-9a-fx.]+$/i.test(h))
308
+ return null;
309
+ const parts = h.split('.');
310
+ if (parts.length === 0 || parts.length > 4)
311
+ return null;
312
+ const nums = [];
313
+ for (const p of parts) {
314
+ if (p === '')
315
+ return null;
316
+ let n;
317
+ if (/^0x[0-9a-f]+$/i.test(p))
318
+ n = parseInt(p, 16);
319
+ else if (/^0[0-7]+$/.test(p))
320
+ n = parseInt(p, 8);
321
+ else if (/^[0-9]+$/.test(p))
322
+ n = parseInt(p, 10);
323
+ else
324
+ return null;
325
+ if (!Number.isFinite(n) || n < 0)
326
+ return null;
327
+ nums.push(n);
328
+ }
329
+ // Fold the trailing part as a "rest" value per inet_aton semantics.
330
+ let value;
331
+ if (nums.length === 1) {
332
+ value = nums[0];
333
+ }
334
+ else {
335
+ const last = nums[nums.length - 1];
336
+ const head = nums.slice(0, -1);
337
+ if (head.some(n => n > 255))
338
+ return null;
339
+ const restMax = Math.pow(256, 4 - head.length);
340
+ if (last >= restMax)
341
+ return null;
342
+ value = last;
343
+ for (let i = 0; i < head.length; i++) {
344
+ value += head[i] * Math.pow(256, 3 - i);
345
+ }
346
+ }
347
+ if (value < 0 || value > 0xffffffff)
348
+ return null;
349
+ return [
350
+ (value >>> 24) & 0xff,
351
+ (value >>> 16) & 0xff,
352
+ (value >>> 8) & 0xff,
353
+ value & 0xff,
354
+ ].join('.');
355
+ }
356
+ /**
357
+ * Check whether a concrete IP address (already resolved, or a literal) points
358
+ * at a private/loopback/link-local/metadata range. Use this after DNS
359
+ * resolution to defeat DNS-rebinding and numeric-encoding SSRF.
360
+ */
361
+ export function isPrivateIp(ip) {
362
+ const canon = canonicalizeIpv4(ip);
363
+ const candidate = (canon ?? ip).toLowerCase();
364
+ if (PRIVATE_NETWORK_HOSTNAMES.has(candidate))
365
+ return true;
366
+ if (PRIVATE_NETWORK_CIDRS.some(re => re.test(candidate)))
367
+ return true;
368
+ return false;
369
+ }
295
370
  export function isPrivateHost(hostname) {
296
371
  const lower = hostname.toLowerCase();
297
372
  if (_networkOverrides.allowAll)
@@ -300,6 +375,12 @@ export function isPrivateHost(hostname) {
300
375
  return false;
301
376
  if (_networkOverrides.blockedHosts?.has(lower))
302
377
  return true;
378
+ // Normalize numeric IPv4 encodings (decimal/hex/octal/short forms) so that
379
+ // e.g. 2130706433, 0x7f000001 and 127.1 are all recognized as loopback.
380
+ const canonical = canonicalizeIpv4(lower);
381
+ if (canonical && (PRIVATE_NETWORK_HOSTNAMES.has(canonical) || PRIVATE_NETWORK_CIDRS.some(re => re.test(canonical)))) {
382
+ return true;
383
+ }
303
384
  if (PRIVATE_NETWORK_HOSTNAMES.has(lower))
304
385
  return true;
305
386
  if (PRIVATE_NETWORK_SUFFIXES.some(s => lower.endsWith(s)))
@@ -70,7 +70,7 @@ function scoreFiles(analysis, index) {
70
70
  deps.add(imp.to);
71
71
  importGraph.set(imp.from, deps);
72
72
  }
73
- const recentFiles = new Set(index.files
73
+ const recentFiles = new Set([...index.files]
74
74
  .sort((a, b) => b.modifiedAt - a.modifiedAt)
75
75
  .slice(0, 20)
76
76
  .map((f) => f.relativePath));
@@ -1,6 +1,7 @@
1
1
  export class CostTracker {
2
2
  entries = [];
3
3
  sessionStart = Date.now();
4
+ restoredRequestCount = 0;
4
5
  record(model, usage) {
5
6
  this.entries.push({
6
7
  model,
@@ -24,7 +25,10 @@ export class CostTracker {
24
25
  return this.entries.reduce((s, e) => s + e.completionTokens, 0);
25
26
  }
26
27
  get requestCount() {
27
- return this.entries.length;
28
+ // The single restored aggregate entry stands in for the previous session's
29
+ // request count, tracked separately so we don't over-count live requests.
30
+ const liveEntries = this.entries.filter((e) => e.model !== '(previous session)').length;
31
+ return liveEntries + this.restoredRequestCount;
28
32
  }
29
33
  get sessionDuration() {
30
34
  const s = Math.floor((Date.now() - this.sessionStart) / 1000);
@@ -57,25 +61,26 @@ export class CostTracker {
57
61
  }
58
62
  reset() {
59
63
  this.entries = [];
64
+ this.restoredRequestCount = 0;
60
65
  this.sessionStart = Date.now();
61
66
  }
62
67
  restore(state) {
63
68
  this.entries = [];
64
- if (state.requestCount > 0 && state.totalTokens >= 0) {
65
- const count = Math.max(1, state.requestCount);
66
- const perReqTokens = Math.floor(state.totalTokens / count);
67
- const remainder = state.totalTokens - perReqTokens * count;
68
- const perReqCost = state.totalCost / count;
69
- for (let i = 0; i < count; i++) {
70
- this.entries.push({
71
- model: '(previous session)',
72
- promptTokens: 0,
73
- completionTokens: 0,
74
- totalTokens: perReqTokens + (i === 0 ? remainder : 0),
75
- cost: perReqCost,
76
- timestamp: Date.now(),
77
- });
78
- }
69
+ // Store a single aggregate entry rather than fabricating `requestCount`
70
+ // synthetic entries — a corrupted/huge count would otherwise allocate
71
+ // unboundedly. The original request count is preserved separately.
72
+ this.restoredRequestCount = Number.isFinite(state.requestCount) && state.requestCount > 0
73
+ ? Math.floor(state.requestCount)
74
+ : 0;
75
+ if (this.restoredRequestCount > 0 && state.totalTokens >= 0) {
76
+ this.entries.push({
77
+ model: '(previous session)',
78
+ promptTokens: 0,
79
+ completionTokens: 0,
80
+ totalTokens: state.totalTokens,
81
+ cost: state.totalCost,
82
+ timestamp: Date.now(),
83
+ });
79
84
  }
80
85
  }
81
86
  getState() {
@@ -18,13 +18,13 @@ async function getBranch(cwd) {
18
18
  return (await run('git branch --show-current', cwd)) || (await run('git rev-parse --short HEAD', cwd)) || 'unknown';
19
19
  }
20
20
  async function getRemoteBranch(cwd) {
21
- return run('git rev-parse --abbrev-ref @{upstream} 2>/dev/null', cwd);
21
+ return run('git rev-parse --abbrev-ref @{upstream}', cwd);
22
22
  }
23
23
  async function getRemoteUrl(cwd) {
24
- return run('git remote get-url origin 2>/dev/null', cwd);
24
+ return run('git remote get-url origin', cwd);
25
25
  }
26
26
  async function getAheadBehind(cwd) {
27
- const result = await run('git rev-list --left-right --count @{upstream}...HEAD 2>/dev/null', cwd);
27
+ const result = await run('git rev-list --left-right --count @{upstream}...HEAD', cwd);
28
28
  if (!result)
29
29
  return { ahead: 0, behind: 0 };
30
30
  const parts = result.split('\t');
@@ -33,7 +33,7 @@ async function getAheadBehind(cwd) {
33
33
  return { ahead: Number(parts[1]) || 0, behind: Number(parts[0]) || 0 };
34
34
  }
35
35
  async function getStashCount(cwd) {
36
- const result = await run('git stash list 2>/dev/null', cwd);
36
+ const result = await run('git stash list', cwd);
37
37
  return result ? result.split('\n').filter(Boolean).length : 0;
38
38
  }
39
39
  async function getStatus(cwd) {
@@ -74,7 +74,10 @@ async function getStatus(cwd) {
74
74
  async function getRecentCommits(cwd, count = GIT_CONTEXT_DEFAULTS.recentCommitsCount) {
75
75
  const SEP = '<%>';
76
76
  const format = `%H${SEP}%h${SEP}%s${SEP}%an${SEP}%ar`;
77
- const result = await run(`git log --pretty=format:"${format}" --shortstat -n ${count} 2>/dev/null`, cwd);
77
+ // No shell redirects here: `2>/dev/null` breaks under cmd.exe on Windows
78
+ // (redirects to a literal \dev\null path and fails the whole command).
79
+ // run() already discards stderr and swallows failures.
80
+ const result = await run(`git log --pretty=format:"${format}" --shortstat -n ${count}`, cwd);
78
81
  if (!result)
79
82
  return [];
80
83
  const commits = [];
@@ -20,7 +20,14 @@ function surroundingContext(text, pos, radius = 60) {
20
20
  const end = Math.min(text.length, pos + radius);
21
21
  return text.slice(start, end).replace(/\n/g, ' ').trim();
22
22
  }
23
- export function extractCodeClaims(text) {
23
+ // Blank out fenced code blocks (```...```) with equal-length whitespace so the
24
+ // model's own newly-authored symbols aren't flagged as ungrounded, while
25
+ // keeping character offsets stable for surroundingContext().
26
+ function maskFencedCodeBlocks(text) {
27
+ return text.replace(/```[\s\S]*?(?:```|$)/g, (block) => block.replace(/[^\n]/g, ' '));
28
+ }
29
+ export function extractCodeClaims(rawText) {
30
+ const text = maskFencedCodeBlocks(rawText);
24
31
  const seen = new Set();
25
32
  const claims = [];
26
33
  function add(type, value, position) {
package/dist/headless.js CHANGED
@@ -22,6 +22,7 @@ import { loadMemory, autoDetectConventions, saveMemory, memoryToContext, addNote
22
22
  import { buildProjectIndex } from './project-index.js';
23
23
  import { buildProjectContext, contextToInstructions } from './context-builder.js';
24
24
  import { discoverPlugins, getPluginTools, pluginsSummary } from './plugins.js';
25
+ import { isWorkspaceTrusted } from './workspace-trust.js';
25
26
  import { loadCustomCommands, expandCommand, commandsSummary } from './commands.js';
26
27
  import { hooksSummary } from './hooks.js';
27
28
  import { spawnSubAgent, pickCheapestModel } from './sub-agent.js';
@@ -39,6 +40,8 @@ export function parseArgs() {
39
40
  let format;
40
41
  let permissionMode;
41
42
  let outputSchema;
43
+ let worktree = false;
44
+ let worktreeName;
42
45
  for (let i = 0; i < args.length; i++) {
43
46
  if (args[i] === 'run' && i + 1 < args.length) {
44
47
  mode = 'run';
@@ -72,6 +75,13 @@ export function parseArgs() {
72
75
  else if (args[i] === '--ipc') {
73
76
  mode = 'ipc';
74
77
  }
78
+ else if (args[i] === '--worktree' || args[i] === '-w') {
79
+ worktree = true;
80
+ if (i + 1 < args.length && !args[i + 1].startsWith('-') && args[i + 1] !== 'run') {
81
+ worktreeName = args[i + 1];
82
+ i++;
83
+ }
84
+ }
75
85
  else if (args[i] === '--help' || args[i] === '-h') {
76
86
  console.log(`
77
87
  Scalix - Headless Mode
@@ -91,6 +101,7 @@ Options:
91
101
  --format <fmt> Output format: text (default), json, json-schema, patch, markdown
92
102
  --output-format Alias for --format
93
103
  --output-schema Path to JSON schema file (implies --format json-schema)
104
+ --worktree, -w Run in an isolated git worktree (optional: --worktree <name>)
94
105
  --help, -h Show this help
95
106
  `);
96
107
  process.exit(0);
@@ -99,7 +110,7 @@ Options:
99
110
  if (!process.stdin.isTTY && mode === 'interactive') {
100
111
  mode = 'pipe';
101
112
  }
102
- return { mode, prompt, output, format, permissionMode, outputSchema };
113
+ return { mode, prompt, output, format, permissionMode, outputSchema, worktree, worktreeName };
103
114
  }
104
115
  async function buildEnhancedInstructions() {
105
116
  let base = DEFAULT_INSTRUCTIONS;
@@ -120,7 +131,7 @@ async function buildEnhancedInstructions() {
120
131
  const ctx = await buildProjectContext(cwd);
121
132
  return contextToInstructions(base, ctx);
122
133
  }
123
- async function runSinglePrompt(prompt, output, format, outputSchemaPath) {
134
+ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeExit) {
124
135
  const apiKey = process.env[ENV_API_KEY];
125
136
  if (!apiKey) {
126
137
  console.error('Error: SCALIX_API_KEY is not set.\nSet it via: export SCALIX_API_KEY=sk-...\nOr run the interactive setup: scalix');
@@ -148,7 +159,12 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath) {
148
159
  process.exit(1);
149
160
  }
150
161
  const mcpTools = loadMcpServices();
151
- const plugins = await discoverPlugins(process.cwd());
162
+ // Only discover/load project-local plugins in a trusted workspace — plugins
163
+ // dynamically import and execute code from the project directory.
164
+ const plugins = isWorkspaceTrusted() ? await discoverPlugins(process.cwd()) : [];
165
+ if (!isWorkspaceTrusted()) {
166
+ process.stderr.write('Project plugins skipped (untrusted workspace).\n');
167
+ }
152
168
  const pluginTools = getPluginTools(plugins);
153
169
  const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools, ...pluginTools];
154
170
  const instructions = await buildEnhancedInstructions();
@@ -191,16 +207,22 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath) {
191
207
  contextRetrieval: true,
192
208
  });
193
209
  let fullResponse = '';
194
- agent.on('stream:end', (text) => { fullResponse = text; });
195
210
  agent.on('tool:call', (name) => process.stderr.write(`\x1b[33m▶ ${name}\x1b[0m\n`));
196
211
  agent.on('verify:pass', (r) => process.stderr.write(`\x1b[32m✓ ${r.checker} passed\x1b[0m\n`));
197
212
  agent.on('verify:fail', (r) => process.stderr.write(`\x1b[31m✗ ${r.checker} failed: ${r.output.split('\n')[0]}\x1b[0m\n`));
198
213
  agent.on('error', (err) => process.stderr.write(`\x1b[31mError: ${err.message}\x1b[0m\n`));
214
+ agent.on('usage', (data) => {
215
+ costTracker.record(data.model || selectedModel[0], data);
216
+ });
199
217
  try {
200
- await agent.send(prompt);
218
+ // Use the return value (applies JSON extraction for --format json/json-schema)
219
+ // rather than the raw stream text.
220
+ fullResponse = await agent.send(prompt);
201
221
  }
202
222
  catch (err) {
203
223
  console.error(`Error: ${err.message}`);
224
+ if (beforeExit)
225
+ await beforeExit();
204
226
  process.exit(1);
205
227
  }
206
228
  let formatted;
@@ -234,9 +256,11 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath) {
234
256
  process.stdout.write(formatted + '\n');
235
257
  }
236
258
  await closeBrowser();
259
+ if (beforeExit)
260
+ await beforeExit();
237
261
  process.exit(0);
238
262
  }
239
- async function runPipeMode() {
263
+ async function runPipeMode(beforeExit) {
240
264
  const MAX_PIPE_BYTES = 50 * 1024 * 1024;
241
265
  const chunks = [];
242
266
  let totalBytes = 0;
@@ -246,6 +270,8 @@ async function runPipeMode() {
246
270
  totalBytes += Buffer.byteLength(str);
247
271
  if (totalBytes > MAX_PIPE_BYTES) {
248
272
  console.error(`Error: Pipe input exceeds ${MAX_PIPE_BYTES / 1024 / 1024}MB limit.`);
273
+ if (beforeExit)
274
+ await beforeExit();
249
275
  process.exit(1);
250
276
  }
251
277
  chunks.push(str);
@@ -253,14 +279,18 @@ async function runPipeMode() {
253
279
  }
254
280
  catch (err) {
255
281
  console.error(`Error reading pipe input: ${err.message}`);
282
+ if (beforeExit)
283
+ await beforeExit();
256
284
  process.exit(1);
257
285
  }
258
286
  const prompt = chunks.join('').trim();
259
287
  if (!prompt) {
260
288
  console.error('Error: No input received from stdin.');
289
+ if (beforeExit)
290
+ await beforeExit();
261
291
  process.exit(1);
262
292
  }
263
- await runSinglePrompt(prompt);
293
+ await runSinglePrompt(prompt, undefined, undefined, undefined, beforeExit);
264
294
  }
265
295
  async function runInteractive() {
266
296
  const apiKey = process.env[ENV_API_KEY];
@@ -281,7 +311,12 @@ async function runInteractive() {
281
311
  throw new Error('No models configured. Set SCALIX_MODEL env var or run interactive setup.');
282
312
  }
283
313
  const mcpTools = loadMcpServices();
284
- const plugins = await discoverPlugins(process.cwd());
314
+ // Only discover/load project-local plugins in a trusted workspace — plugins
315
+ // dynamically import and execute code from the project directory.
316
+ const plugins = isWorkspaceTrusted() ? await discoverPlugins(process.cwd()) : [];
317
+ if (!isWorkspaceTrusted()) {
318
+ process.stderr.write('Project plugins skipped (untrusted workspace).\n');
319
+ }
285
320
  const pluginTools = getPluginTools(plugins);
286
321
  const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools, ...pluginTools];
287
322
  const instructions = await buildEnhancedInstructions();
@@ -363,20 +398,27 @@ async function runInteractive() {
363
398
  console.log(`\x1b[36mPlugins:\x1b[0m ${plugins.map((p) => p.manifest.name).join(', ')}`);
364
399
  }
365
400
  console.log('');
366
- function shutdown() {
401
+ let shuttingDown = false;
402
+ async function shutdown() {
403
+ if (shuttingDown)
404
+ return;
405
+ shuttingDown = true;
367
406
  const msgs = agent.getMessages();
368
407
  if (msgs.length > 0) {
369
- saveSession(sessionId, msgs, agent.getModels()[0], undefined, costTracker.getState()).catch(() => { });
408
+ // Await the save so sessions aren't lost on SIGINT (process.exit would
409
+ // otherwise cut off the fire-and-forget write).
410
+ await saveSession(sessionId, msgs, agent.getModels()[0], undefined, costTracker.getState()).catch(() => { });
370
411
  }
371
412
  if (costTracker.totalTokens > 0) {
372
413
  console.log(`\n\x1b[36mSession: ${costTracker.totalTokens.toLocaleString()} tokens${costTracker.totalCost > 0 ? ` ($${costTracker.totalCost.toFixed(4)})` : ''}\x1b[0m`);
373
414
  }
374
415
  console.log('Goodbye!');
375
416
  rl.close();
376
- closeBrowser().finally(() => process.exit(0));
417
+ await closeBrowser().catch(() => { });
418
+ process.exit(0);
377
419
  }
378
- process.on('SIGINT', shutdown);
379
- process.on('SIGTERM', shutdown);
420
+ process.on('SIGINT', () => { shutdown().catch(() => process.exit(1)); });
421
+ process.on('SIGTERM', () => { shutdown().catch(() => process.exit(1)); });
380
422
  const handleCommand = async (input) => {
381
423
  const cmd = input.split(' ')[0].toLowerCase();
382
424
  if (cmd === '/help') {
@@ -645,7 +687,7 @@ async function runInteractive() {
645
687
  return true;
646
688
  }
647
689
  if (cmd === '/quit' || cmd === '/exit') {
648
- shutdown();
690
+ await shutdown();
649
691
  return true;
650
692
  }
651
693
  const customCmd = customCmds.find((c) => cmd === `/${c.name}`);
@@ -713,28 +755,69 @@ async function runInteractive() {
713
755
  }
714
756
  async function main() {
715
757
  setSandboxRoot(process.cwd());
716
- const { mode, prompt, output, format, permissionMode: cliPermissionMode, outputSchema } = parseArgs();
758
+ const { mode, prompt, output, format, permissionMode: cliPermissionMode, outputSchema, worktree, worktreeName } = parseArgs();
717
759
  if (cliPermissionMode) {
718
760
  process.env.SCALIX_PERMISSION_MODE = cliPermissionMode;
719
761
  }
720
- switch (mode) {
721
- case 'run':
722
- if (!prompt) {
723
- console.error('Error: No prompt provided. Usage: scalix run "your prompt"');
724
- process.exit(1);
725
- }
726
- await runSinglePrompt(prompt, output, format, outputSchema);
727
- break;
728
- case 'pipe':
729
- await runPipeMode();
730
- break;
731
- case 'ipc':
732
- await runIpcServer();
733
- break;
734
- case 'interactive':
735
- default:
736
- await runInteractive();
737
- break;
762
+ // Set up worktree isolation if requested
763
+ let worktreeInfo = null;
764
+ // Capture the original cwd BEFORE any process.chdir so cleanup can restore it
765
+ // and resolve the worktree parent correctly.
766
+ const originalCwd = process.cwd();
767
+ let cleanup = async () => { };
768
+ if (worktree) {
769
+ const { createWorktree, removeWorktree } = await import('./worktree.js');
770
+ const name = worktreeName || `session-${Date.now().toString(36)}`;
771
+ try {
772
+ worktreeInfo = await createWorktree(name, originalCwd);
773
+ process.chdir(worktreeInfo.path);
774
+ setSandboxRoot(worktreeInfo.path);
775
+ console.log(`Working in isolated worktree: ${worktreeInfo.branch} → ${worktreeInfo.path}`);
776
+ }
777
+ catch (err) {
778
+ console.error(`Failed to create worktree: ${err.message}`);
779
+ process.exit(1);
780
+ }
781
+ let cleanedUp = false;
782
+ cleanup = async () => {
783
+ if (cleanedUp || !worktreeInfo)
784
+ return;
785
+ cleanedUp = true;
786
+ try {
787
+ // Return to the original directory before removing the worktree so we
788
+ // are not standing inside the directory being deleted.
789
+ process.chdir(originalCwd);
790
+ await removeWorktree(worktreeInfo, originalCwd);
791
+ }
792
+ catch { }
793
+ };
794
+ // 'exit' handlers cannot run async work — SIGINT awaits cleanup explicitly.
795
+ process.on('SIGINT', () => { cleanup().then(() => process.exit(0)).catch(() => process.exit(1)); });
796
+ }
797
+ try {
798
+ switch (mode) {
799
+ case 'run':
800
+ if (!prompt) {
801
+ console.error('Error: No prompt provided. Usage: scalix run "your prompt"');
802
+ await cleanup();
803
+ process.exit(1);
804
+ }
805
+ await runSinglePrompt(prompt, output, format, outputSchema, cleanup);
806
+ break;
807
+ case 'pipe':
808
+ await runPipeMode(cleanup);
809
+ break;
810
+ case 'ipc':
811
+ await runIpcServer();
812
+ break;
813
+ case 'interactive':
814
+ default:
815
+ await runInteractive();
816
+ break;
817
+ }
818
+ }
819
+ finally {
820
+ await cleanup();
738
821
  }
739
822
  }
740
823
  main().catch((err) => {