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/PARITY-PLAN.md +116 -0
- package/bin/scalix.js +1 -0
- package/dist/agent.js +69 -31
- package/dist/api-client.js +44 -9
- package/dist/auto-verify.js +3 -1
- package/dist/cli.js +931 -183
- package/dist/commands.js +70 -2
- package/dist/constants.js +87 -6
- package/dist/context-retriever.js +1 -1
- package/dist/cost-tracker.js +21 -16
- package/dist/git-context.js +8 -5
- package/dist/grounding-checker.js +8 -1
- package/dist/headless.js +116 -33
- package/dist/hooks.js +195 -13
- package/dist/injection-detector.js +18 -1
- package/dist/ipc-server.js +7 -1
- package/dist/keychain.js +222 -0
- package/dist/markdown.js +49 -3
- package/dist/model-router.js +11 -0
- package/dist/plugins.js +132 -0
- package/dist/project-index.js +20 -1
- package/dist/sandbox.js +139 -9
- package/dist/scoped-rules.js +10 -1
- package/dist/sessions.js +65 -6
- package/dist/settings.js +124 -4
- package/dist/skills.js +269 -0
- package/dist/sub-agent.js +59 -2
- package/dist/terminal-notify.js +73 -0
- package/dist/text-utils.js +146 -0
- package/dist/token-counter.js +10 -1
- package/dist/tool-helper.js +12 -4
- package/dist/tools-browser.js +65 -6
- package/dist/tools-extra.js +44 -14
- package/dist/tools-web.js +76 -22
- package/dist/tools.js +27 -2
- package/dist/workspace-trust.js +90 -0
- package/package.json +3 -2
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:
|
|
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
|
-
|
|
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
|
+
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
|
|
242
|
+
// ─── Git Identity ───
|
|
243
243
|
export const GIT_BOT_IDENTITY = {
|
|
244
|
-
name: '
|
|
245
|
-
email: '
|
|
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));
|
package/dist/cost-tracker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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() {
|
package/dist/git-context.js
CHANGED
|
@@ -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}
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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().
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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) => {
|